From cb4c11b869f2c9e2bcba89ab6deec146b678be68 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 15:20:47 +0800 Subject: [PATCH 001/229] Add web multimodal image attachments --- ...ge-input-and-durable-attachments.i18n.yaml | 6 + ...dal-image-input-and-durable-attachments.md | 210 ++++++++++++++++++ ...-image-input-and-durable-attachments.zh.md | 210 ++++++++++++++++++ apps/web/tests/smoke-fixture.e2e.ts | 99 ++++++++- docs/capability-seams.md | 11 +- docs/config-catalog.md | 21 ++ docs/cordis-catalog/services.md | 26 ++- docs/core-data-structures/attachment.md | 70 ++++++ docs/core-data-structures/core.md | 8 +- docs/core-data-structures/llm-streaming.md | 1 + knip.json | 5 + packages/README.md | 1 + packages/attachment/README.md | 10 + .../attachment/attachment-local/README.md | 19 ++ .../attachment/attachment-local/package.json | 30 +++ .../attachment/attachment-local/src/image.ts | 101 +++++++++ .../attachment/attachment-local/src/index.ts | 74 ++++++ .../attachment-local/src/invariant.ts | 20 ++ .../attachment/attachment-local/src/store.ts | 121 ++++++++++ .../attachment-local/tests/store.spec.ts | 96 ++++++++ .../attachment/attachment-local/tsconfig.json | 12 + packages/attachment/attachment/README.md | 19 ++ packages/attachment/attachment/package.json | 27 +++ packages/attachment/attachment/src/index.ts | 51 +++++ .../attachment/attachment/src/invariant.ts | 20 ++ packages/attachment/attachment/src/types.ts | 75 +++++++ packages/attachment/attachment/tsconfig.json | 11 + packages/client/connection/package.json | 1 + packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 82 ++++++- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/tests/fake-api.ts | 3 + .../client/connection/tests/fixture.spec.ts | 29 ++- packages/client/connection/tsconfig.json | 3 + packages/client/runtime/package.json | 1 + .../src/client/sessions/conversation.ts | 6 + .../runtime/src/client/sessions/session.ts | 25 ++- .../client/runtime/tests/conversation.spec.ts | 12 +- packages/client/runtime/tests/fake-api.ts | 3 + packages/client/runtime/tests/session.spec.ts | 15 ++ packages/client/runtime/tsconfig.json | 3 + packages/client/ui-conversation/package.json | 1 + .../ui-conversation/src/client/apply.ts | 24 +- .../src/client/chat/AssistantMarkdown.tsx | 11 +- .../src/client/chat/ChatView.tsx | 26 ++- .../src/client/chat/MessageImage.module.css | 53 +++++ .../src/client/chat/MessageImage.tsx | 70 ++++++ .../src/client/chat/MessageItem.module.css | 11 +- .../src/client/chat/MessageItem.tsx | 38 +++- .../src/client/chat/register.ts | 6 +- .../src/client/contract/slots.ts | 22 +- .../src/client/contract/views.ts | 6 + .../ui-conversation/src/client/service.ts | 153 ++++++++++++- .../src/client/skeleton/ConversationRoot.tsx | 20 +- .../src/client/skeleton/EmptyState.tsx | 37 ++- .../client/skeleton/ImageLightbox.module.css | 34 +++ .../src/client/skeleton/ImageLightbox.tsx | 34 +++ .../src/client/skeleton/InputBar.module.css | 71 ++++++ .../src/client/skeleton/InputBar.tsx | 108 ++++++++- .../ui-conversation/src/client/stores.ts | 30 ++- .../tests/apply-inject.spec.tsx | 8 +- .../ui-conversation/tests/chat-store.spec.ts | 16 +- .../ui-conversation/tests/input-bar.spec.tsx | 86 +++++++ .../tests/message-image.spec.tsx | 44 ++++ .../tests/selection-survival.spec.ts | 2 +- .../tests/service-orchestration.spec.ts | 23 ++ .../tests/skeleton-branches.spec.tsx | 6 + .../ui-conversation/tests/skeleton.spec.tsx | 5 +- packages/client/ui-conversation/tsconfig.json | 3 + .../client/ui-trajectory/tests/views.spec.tsx | 3 + .../cordis/tool-cordis/src/api-catalog.ts | 50 ++++- packages/host/apiproxy/package.json | 1 + packages/host/apiproxy/src/api/host.schema.ts | 18 ++ packages/host/apiproxy/src/api/host.ts | 6 + packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 1 + packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 1 + .../host/apiproxy/src/api/sessions.schema.ts | 43 +++- packages/host/apiproxy/src/api/sessions.ts | 15 +- packages/host/apiproxy/src/fetch/client.ts | 4 + packages/host/apiproxy/src/fetch/handler.ts | 2 + .../apiproxy/tests/client-handler.spec.ts | 4 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 6 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 19 +- packages/host/apiproxy/tsconfig.json | 3 + packages/host/runtime/package.json | 2 + packages/host/runtime/src/api-proxy.ts | 120 +++++++++- packages/host/runtime/src/boot.ts | 13 ++ .../host/runtime/tests/host-runtime.spec.ts | 93 +++++++- packages/host/runtime/tsconfig.json | 6 + packages/llm/llm-deepseek/src/adapter.ts | 2 + packages/llm/llm-deepseek/src/serialize.ts | 15 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 12 +- .../llm/llm-deepseek/tests/serialize.spec.ts | 14 ++ packages/llm/llm-pi-ai/package.json | 2 + packages/llm/llm-pi-ai/src/adapter.ts | 25 ++- packages/llm/llm-pi-ai/src/context.ts | 153 ++++++++++--- packages/llm/llm-pi-ai/src/index.ts | 6 +- packages/llm/llm-pi-ai/src/replay.ts | 2 + packages/llm/llm-pi-ai/tests/adapter.spec.ts | 1 + packages/llm/llm-pi-ai/tests/convert.spec.ts | 46 +++- packages/llm/llm-pi-ai/tsconfig.json | 3 + packages/llm/llm/package.json | 2 + packages/llm/llm/src/index.ts | 2 + packages/llm/llm/src/types.ts | 24 ++ packages/llm/llm/tsconfig.json | 3 + packages/llm/token-meter/src/index.ts | 11 + packages/ui/acp/src/codec.ts | 2 + pnpm-lock.yaml | 58 +++++ python/sdk-runtime/package.json | 1 + scripts/gen-cordis-catalog.ts | 3 + scripts/gen-doc-graphs.ts | 10 + scripts/type-equiv.manifest.json | 25 +++ .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 2 + 116 files changed, 3177 insertions(+), 151 deletions(-) create mode 100644 .agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md create mode 100644 .agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md create mode 100644 docs/core-data-structures/attachment.md create mode 100644 packages/attachment/README.md create mode 100644 packages/attachment/attachment-local/README.md create mode 100644 packages/attachment/attachment-local/package.json create mode 100644 packages/attachment/attachment-local/src/image.ts create mode 100644 packages/attachment/attachment-local/src/index.ts create mode 100644 packages/attachment/attachment-local/src/invariant.ts create mode 100644 packages/attachment/attachment-local/src/store.ts create mode 100644 packages/attachment/attachment-local/tests/store.spec.ts create mode 100644 packages/attachment/attachment-local/tsconfig.json create mode 100644 packages/attachment/attachment/README.md create mode 100644 packages/attachment/attachment/package.json create mode 100644 packages/attachment/attachment/src/index.ts create mode 100644 packages/attachment/attachment/src/invariant.ts create mode 100644 packages/attachment/attachment/src/types.ts create mode 100644 packages/attachment/attachment/tsconfig.json create mode 100644 packages/client/ui-conversation/src/client/chat/MessageImage.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/MessageImage.tsx create mode 100644 packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx create mode 100644 packages/client/ui-conversation/tests/message-image.spec.tsx diff --git a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml new file mode 100644 index 0000000000..43f81f5752 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 14ad4e5ceb28fd7df8b639db403be8accd8c2028 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 5752247b080ee1be0b0bfbbf7a9fb7486ff6c41b diff --git a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md new file mode 100644 index 0000000000..14ad4e5ceb --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -0,0 +1,210 @@ +# Agent Note: Web multimodal image input and durable attachments + +Status: proposed + +English | [中文](2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md) + +## Problem + +The Web composer accepts only text: `InputBar` receives a string draft, `ConversationService.send()` creates text content, and the host forwards that content to the agent. Users cannot paste an image, inspect it before sending, submit an image-only prompt, or recover sent images from history. + +This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../implemented/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content. + +The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [GUI layering and RPC protocol](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle. + +Peer products converge on an attachment rail above the editor, but their storage choices differ. Codex-style paths such as `/var/folders/.../codex-clipboard-*.png` are reasonable intake staging locations, not durable message identities: the operating system may delete them, another host cannot read them, and a resumed session cannot rely on them. + +## Proposal + +Add pasted or dropped raster images to the Web composer as the first consumer of a durable attachment capability. Unsent files remain temporary client-owned draft state. The host validates and durably commits every accepted user image before appending its message event. A provider adapter that produces structured image output must durably commit the output before appending its assistant block. Canonical user and assistant content contains only role-neutral `ImageBlock` references. + +Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-only or mixed prompts, historical user and assistant image rendering, and original-image preview on double-click. File picking, generic files, PDF, audio, video, image copying, and a custom context menu are separate follow-ups. + +### Product behavior + +- Pasting or dropping one or more supported images adds ordered thumbnails above the textarea without inserting placeholder text. Dragging files over the composer highlights the drop target. +- The rail is shared by the empty-state and resident composers, is hidden when empty, and scrolls horizontally instead of widening the composer. +- Each approximately 72-by-72-pixel thumbnail has a remove action and opens its original draft image on double-click. +- A prompt may contain text and images or images only. Pure text paste remains native browser behavior; the paste handler prevents the default only when it accepts an image file. File drops on the composer always prevent browser navigation, accept supported images, and report unsupported files locally. +- A failed send restores the complete text and image draft. Removal, successful send, and session-scope disposal revoke obsolete object URLs. +- Historical user and assistant images use one `MessageImage` control. Inline images preserve intrinsic aspect ratio, do not upscale, and stay within a 240-by-240-pixel box. +- Double-clicking a message image opens the stored original in a viewport-bounded modal. Escape, the close control, and backdrop activation close it and restore focus. +- Version one does not override the browser context menu and provides no explicit image-copy action. + +### Storage lifecycle and ownership + +The persistence boundary is message acceptance, not paste: + +| State | Allowed representation | Durability and ordering | +| --- | --- | --- | +| Unsent user draft | Browser `File` plus object URL; a native client may use an OS temporary file such as `/var/...` | Temporary and client-owned. It may disappear on reload or process exit and never appears in a session event. | +| Accepted user image | Immutable object below `DSH_HOME` plus `ImageAttachmentRef` | The host commits every image before `agent.send()` or `agent.steer()` can append the owning user event. | +| Structured model image output | Immutable object below `DSH_HOME` plus `ImageAttachmentRef` | The provider adapter commits the bytes before it emits a completed image block or assistant message event. Temporary URLs, paths, and base64 are forbidden in the event. | + +The framework-owned chat store keeps the per-session draft text and ordered attachment identifiers. `ConversationService` owns the corresponding browser-only `File` and object-URL registry: + +```ts +export {} + +interface ChatStoreState { + selection: object | null + draft: string + imageIds: string[] + view: string | null +} + +interface ComposerAttachment { + id: string + file: File + previewUrl: string +} +``` + +This split uses the slots framework's store seat and bound actions as the single subscription path for UI state while keeping non-serializable browser objects out of persisted JSON. Draft text and ordered image identifiers continue to use `localStorage`; after a reload, `ConversationRoot` prunes identifiers whose runtime objects no longer exist. Unsent images therefore do not survive reload because browser `File` and object URLs are not durable. A native client may stage input in an OS temporary directory, but it must treat that path exactly like the browser object URL: delete it when no longer needed and copy the bytes into the durable store before message acceptance. + +The local attachment backend resolves an explicit `dshHome`, then `$DSH_HOME`, then `~/.dsh`. It stores content-addressed objects below `$DSH_HOME/attachments/v1/objects//` with owner-only directory and file permissions. A temporary file is written, synchronized, and atomically published before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier, and every read verifies the digest, media type, byte length, width, and height. + +The store performs no automatic deletion in version one. Sent user images and model-generated images remain reachable for history, resume, and fork. Reference-aware garbage collection needs a separate design because an age-only rule can delete data still referenced by a durable session. + +### Durable content and prompt wire + +The attachment seam exposes immutable image write and verified read operations. The canonical metadata is deliberately narrower than a generic file record: + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +type AttachmentId = Branded<'AttachmentId'> + +interface ImageAttachmentRef { + attachmentId: AttachmentId + mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + bytes: number + width: number + height: number + name?: string +} + +interface ImageBlock { + type: 'image' + attachment: ImageAttachmentRef +} +``` + +`ImageBlock` joins the merge-extensible core `ContentBlockMap` and is valid in either user or assistant content. It never carries base64, an object URL, a filesystem path, or a provider-owned locator. This keeps the session event plus immutable object store sufficient to reconstruct the exact model-visible image. + +The browser cannot mint a durable reference, so `session.prompt` accepts a narrow intake union rather than canonical `ContentBlock[]`: + +```ts +export {} + +type PromptInputPart = + | { type: 'text'; text: string } + | { + type: 'image' + mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + data: string + name?: string + } +``` + +Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, magic-byte MIME, intrinsic dimensions, and decoded-pixel count. Only after every image succeeds does it call the agent with normalized text and durable image blocks. A failure appends no user event and exposes no attachment path or raw bytes. + +`session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client caches the resulting object URL by session and attachment identifier for its service lifetime and revokes it on disposal. + +### Model capabilities and provider behavior + +Model catalog entries gain optional merge-extensible input and output modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. + +The host is the authoritative preflight boundary. If the selected model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. Immediate intake-time rejection in the UI may be added after model selection is exposed consistently across every Web entry path. + +The Pi-AI adapter is the first visual-input route: it resolves each durable reference through `ctx.attachments` and emits native image content only for models that declare image input. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. + +Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. + +Token estimation accounts for image dimensions without counting base64 or attachment locators as text. Provider-reported usage remains authoritative. ACP renders an explicit image marker until that protocol surface gains native image support rather than silently omitting the block. + +### History rendering and original preview + +History folding preserves `ImageBlock` in both user and assistant messages. User images align to the trailing edge above their text; assistant images align to the leading narration flow. `MessageImage` derives a stable inline box from recorded dimensions, resolves bytes through the session-authorized loader, uses `object-fit: contain`, and turns a missing or corrupt object into a retryable error control. + +Composer thumbnails and each `MessageImage` own ephemeral original-preview state and invoke the same pure `ImageLightbox`. The modal uses the already resolved original object URL, constrains only display size, focuses its close control, and restores the previous focus target when closed. + +### Limits and trust boundaries + +Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and are projected to the client for fast-path guidance; host validation remains authoritative. + +Malformed base64, unsupported or mismatched media, truncated headers, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. + +### Package and surface changes + +| Surface | Responsibility | +| --- | --- | +| `packages/attachment/attachment` | Opaque attachment identifier, image reference, limits, failures, and `ctx.attachments` service. | +| `packages/attachment/attachment-local` | Private content-addressed storage, image-header validation, integrity verification, and configuration. | +| `packages/llm/llm` and `packages/llm/token-meter` | Role-neutral `ImageBlock`, modality metadata, and image cost estimation. | +| `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. | +| `packages/llm/llm-deepseek` | Reject image content explicitly. | +| `packages/host/apiproxy` and `packages/host/runtime` | Narrow upload wire, persist-before-event ordering, session-authorized reads, limits, and model preflight. | +| `packages/client/connection` and `packages/client/runtime` | Wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. | +| `packages/client/ui-conversation` | Per-session draft images, attachment rail, user and assistant image controls, and original preview. | +| `packages/ui/acp` | Explicit fallback rendering for image blocks. | + +The attachment packages form the interface/implementation side of one capability seam. Composer behavior stays in the conversation object layer, provider conversion stays in adapters, and no change is required in `agent-loop`. + +### Delivery + +1. Land the attachment seam, role-neutral image block, image-aware token estimation, Pi-AI input conversion, DeepSeek rejection, and durable host ordering. +2. Land the Web upload/read protocol, in-memory draft images, paste/drop rail, user and assistant history rendering, double-click preview, and assembled keyless Web coverage. +3. Add immediate intake-time capability feedback when active model selection is consistently available to the composer. +4. Propose file picking, generic files/PDF, audio/video, durable draft staging, output-provider certification, and reference-aware garbage collection independently. + +No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. + +## Alternatives considered + +### Keep every intake image in `/var` or another temporary directory + +Temporary storage is appropriate before send, including for a native client that receives clipboard files through the operating system. It is not appropriate after acceptance: cleanup is outside the harness's control, paths are host-specific, and resume or fork can outlive the file. The proposal permits temporary staging but copies accepted bytes into `DSH_HOME` before the event. + +### Persist immediately on paste or drop + +Immediate persistence makes drafts reload-resistant but creates durable objects before a session or message owns them, which requires quota, orphan lifetime, and cleanup policy. Version one keeps the unsent draft temporary and makes send acceptance the durability boundary. + +### Inline base64 in messages and session logs + +This duplicates binary data across RPC, events, history pages, forks, compaction, and browser storage, and invites token accounting to treat encoding text as model text. One immutable object plus small references keeps the durable representation bounded. + +### Use browser object URLs, local paths, or provider URLs as canonical content + +Object URLs expire with the document, local paths are not portable, and provider URLs may expire, track viewers, or expose credentials. They remain temporary transport or preview details only. + +### Use one generic `AttachmentBlock` for images, files, audio, and video + +Composer presentation can use a generic attachment rail, but provider semantics are modality-specific. Images are native multimodal input; PDFs may be provider files or extracted text; video may be native, sampled, or unsupported. A specific `ImageBlock` forces every consumer to handle or reject the modality explicitly. + +### Rely on UI capability checks or silently filter images + +UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalogued model paths. Silent filtering changes user intent. Provider enforcement remains mandatory, while UI checks are optional earlier feedback. + +## Acceptance criteria + +- Pasting or dropping one or more supported images shows ordered removable thumbnails above both composer variants without changing textarea text; drag-over highlights the target, unsupported drops cannot navigate away, and image-only send works. +- Unsent browser images exist only as `File` and object URLs, survive session switches in memory, do not enter `localStorage`, and are revoked after removal, accepted send, or service disposal. +- Every accepted user image is committed below resolved `DSH_HOME` before its `user/message` event. The event contains only `ImageBlock` references and never base64 or temporary paths. +- Structured assistant images can be represented only by a durable `ImageBlock`; a future output adapter must persist bytes before emitting the assistant event, while Markdown image URLs remain text. +- Cold history renders user and assistant image references through the same bounded control. Double-click opens the original; Escape, backdrop, and close control dismiss it without a custom context menu. +- Session attachment reads fail unless the same session log references the identifier. Missing or corrupt objects fail explicitly and never return unverified bytes. +- Pi-AI emits native input images for a compatible route. DeepSeek and every non-implementing consumer return an explicit unsupported-content failure rather than dropping the block. +- An explicitly text-only active model rejects image send before attachment persistence or session event append; unknown metadata still reaches adapter enforcement and a failed send restores the draft. +- Keyless unit, host integration, client integration, and assembled Chromium coverage exercise persistence ordering, absence of base64 in logs, authorization, paste and drop, image-only send, historical user and assistant images, original preview, and object-URL cleanup. +- The current production adapter set declares text-only output; output-provider certification, file picking, non-image files, video, persistent drafts, and garbage collection remain outside version one. + +## Risks + +- Durable storage grows without garbage collection. Version one chooses replay safety over premature deletion. +- A missing or corrupt object makes exact model reconstruction fail. Failing loud preserves integrity but may prevent that session from continuing until repaired. +- JSON-RPC base64 adds upload memory and roughly one-third encoding overhead. Version-one limits bound it; larger media needs streaming or a binary transport. +- Unsent images do not survive reload. Durable drafts need quota and orphan cleanup rather than reusing message storage implicitly. +- Original preview decodes more pixels than the inline control displays. Pixel limits, one clicked preview, and object-URL disposal bound but do not eliminate transient browser memory. +- Capability metadata may be missing or stale. Host preflight improves feedback, while adapter enforcement remains authoritative. +- A future output provider may require authenticated retrieval before an assistant image can complete, adding latency and a new failure point. Persist-before-event ordering favors replay integrity. diff --git a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md new file mode 100644 index 0000000000..5752247b08 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -0,0 +1,210 @@ +# Agent Note: Web 多模态图片输入与持久附件 + +Status: proposed + +[English](2026-07-22-web-multimodal-image-input-and-durable-attachments.md) | 中文 + +## 问题 + +Web 输入区目前仅接受文本:`InputBar` 接收字符串草稿,`ConversationService.send()` 创建文本内容,宿主再把该内容转发给 agent(智能体)。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词,也无法从历史记录中恢复已发送图片。 + +这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../implemented/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 + +[Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[GUI 分层与 RPC 协议](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。 + +同类产品普遍在编辑器上方设置附件栏,但存储方案各不相同。诸如 `/var/folders/.../codex-clipboard-*.png` 的 Codex 式路径适合作为接收输入时的暂存位置,却不能作为持久消息身份:操作系统可能删除文件,另一台宿主无法读取文件,恢复后的会话也不能依赖文件仍然存在。 + +## 提案 + +把粘贴或拖放的光栅图片作为持久附件能力的首个消费方,接入 Web 输入区。未发送文件仍是由客户端持有的临时草稿状态。宿主在追加相应消息事件前,校验并持久提交每张已接受的用户图片。生成结构化图片输出的提供方适配器在追加相应助手块前,也必须持久提交输出。规范用户内容与助手内容只包含角色无关的 `ImageBlock` 引用。 + +第一版支持粘贴和拖放 PNG、JPEG、WebP 与 GIF,支持仅图片或混合提示词,支持渲染历史用户图片与助手图片,并支持双击预览原图。文件选择、通用文件、PDF、音频、视频、图片复制和自定义上下文菜单分别作为后续工作。 + +### 产品行为 + +- 粘贴或拖放一张或多张受支持的图片后,文本框上方会按顺序显示缩略图,但不会插入占位文本。文件拖入输入区时会高亮放置目标。 +- 空状态输入区与常驻输入区共用附件栏;附件栏为空时隐藏,通过横向滚动避免撑宽输入区。 +- 每个缩略图约为 72 × 72 像素,带有移除操作;双击时打开草稿原图。 +- 提示词可同时包含文本与图片,也可仅包含图片。粘贴纯文本时保持浏览器原生行为;粘贴处理器只有接受图片文件后才阻止默认行为。无论文件是否受支持,在输入区放置文件时都会阻止浏览器导航;系统会接受受支持的图片,并在本地提示哪些文件不受支持。 +- 发送失败时恢复完整的文本与图片草稿。移除、发送成功和会话作用域释放都会撤销过期的对象 URL。 +- 历史用户图片与助手图片共用一个 `MessageImage` 控件。行内图片保持固有宽高比、不放大,并限制在 240 × 240 像素的边界框内。 +- 双击消息图片会在不超出视口的模态框中打开存储的原图。按 Escape、激活关闭控件或激活背景区域都会关闭模态框并恢复焦点。 +- 第一版不覆盖浏览器上下文菜单,也不提供明确的图片复制操作。 + +### 存储生命周期与归属 + +持久化边界是消息被接受,而不是图片被粘贴: + +| 状态 | 允许的表示 | 持久性与顺序 | +| --- | --- | --- | +| 未发送的用户草稿 | 浏览器 `File` 加对象 URL;原生客户端可以使用 `/var/...` 等操作系统临时文件 | 临时且由客户端持有。它可能在重载或进程退出后消失,绝不出现在会话事件中。 | +| 已接受的用户图片 | `DSH_HOME` 下的不可变对象加 `ImageAttachmentRef` | 在 `agent.send()` 或 `agent.steer()` 能够追加所属用户事件前,宿主提交每张图片。 | +| 结构化模型图片输出 | `DSH_HOME` 下的不可变对象加 `ImageAttachmentRef` | 提供方适配器在发出已完成的图片块或助手消息事件前提交字节。事件中禁止出现临时 URL、路径和 base64。 | + +框架持有的 chat store 保存每个会话的草稿文本和有序附件标识符,`ConversationService` 则持有相应的浏览器专用 `File` 与对象 URL 注册表: + +```ts +export {} + +interface ChatStoreState { + selection: object | null + draft: string + imageIds: string[] + view: string | null +} + +interface ComposerAttachment { + id: string + file: File + previewUrl: string +} +``` + +这一拆分让 UI 状态通过 slots 框架的 store 席位和绑定 actions 使用唯一的订阅路径,同时避免把不可序列化的浏览器对象写进持久 JSON。草稿文本和有序图片标识符继续使用 `localStorage`;重载后,`ConversationRoot` 会清理缺少对应运行时对象的标识符。未发送图片因此无法跨重载保留,因为浏览器 `File` 与对象 URL 不具备持久性。原生客户端可以在操作系统临时目录中暂存输入,但必须像对待浏览器对象 URL 一样对待该路径:不再需要时删除,并在消息被接受前把字节复制进持久存储。 + +本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。服务先写入并同步临时文件,再以原子方式发布,之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中,每次读取都会校验摘要、媒体类型、字节长度、宽度和高度。 + +第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。 + +### 持久内容与提示词协议 + +附件服务边界公开不可变图片写入和经过校验的读取操作。规范元数据刻意比通用文件记录更窄: + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +type AttachmentId = Branded<'AttachmentId'> + +interface ImageAttachmentRef { + attachmentId: AttachmentId + mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + bytes: number + width: number + height: number + name?: string +} + +interface ImageBlock { + type: 'image' + attachment: ImageAttachmentRef +} +``` + +`ImageBlock` 加入可合并扩展的核心 `ContentBlockMap`,在用户内容和助手内容中都有效。它绝不携带 base64、对象 URL、文件系统路径或提供方持有的定位符。因此,会话事件与不可变对象存储足以共同重建模型可见的确切图片。 + +浏览器无法生成持久引用,因此 `session.prompt` 接受范围狭窄的接收联合类型,而不是规范 `ContentBlock[]`: + +```ts +export {} + +type PromptInputPart = + | { type: 'text'; text: string } + | { + type: 'image' + mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + data: string + name?: string + } +``` + +Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和解码像素数。只有每张图片都成功后,宿主才会用规范化文本和持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 + +`session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。客户端在服务生命周期内,以会话和附件标识符为键缓存生成的对象 URL,并在释放时撤销它。 + +### 模型能力与提供方行为 + +模型目录项增加可选且可合并扩展的输入与输出模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 + +宿主是权威的前置检查边界。如果所选模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。在所有 Web 入口路径都能一致公开模型选择后,可以在 UI 中增加粘贴时立即拒绝的反馈。 + +Pi-AI 适配器是首条视觉输入路径:它通过 `ctx.attachments` 解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 + +核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 + +token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为文本计数。提供方返回的用量仍是权威值。在 ACP(Agent Client Protocol)接口原生支持图片前,ACP 会渲染明确的图片标记,而不是静默省略该块。 + +### 历史渲染与原图预览 + +历史记录折叠会在用户消息和助手消息中保留 `ImageBlock`。用户图片在文本上方靠尾端对齐;助手图片在叙述流中靠前端对齐。`MessageImage` 根据记录的尺寸派生稳定的行内边界框,通过会话授权加载器解析字节,使用 `object-fit: contain`,并将对象缺失或损坏转换为可重试的错误控件。 + +输入区缩略图和每个 `MessageImage` 各自持有临时原图预览状态,并调用同一个纯 `ImageLightbox`。模态框使用已经解析的原始对象 URL,只限制显示尺寸;它会聚焦关闭控件,并在关闭时把焦点恢复到先前的目标。 + +### 限制与信任边界 + +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引;宿主校验仍是权威结果。 + +格式错误的 base64、不支持或不匹配的媒体、截断的文件头、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 + +### 包与接口变更 + +| 接口 | 职责 | +| --- | --- | +| `packages/attachment/attachment` | 不透明附件标识符、图片引用、限制、错误和 `ctx.attachments` 服务。 | +| `packages/attachment/attachment-local` | 私有内容寻址存储、图片头校验、完整性校验和配置。 | +| `packages/llm/llm` 和 `packages/llm/token-meter` | 角色无关的 `ImageBlock`、模态元数据和图片成本估算。 | +| `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 | +| `packages/llm/llm-deepseek` | 明确拒绝图片内容。 | +| `packages/host/apiproxy` 和 `packages/host/runtime` | 范围狭窄的上传协议、先持久化再追加事件的顺序、会话授权读取、限制和模型前置检查。 | +| `packages/client/connection` 和 `packages/client/runtime` | 协议类型、fixture(测试前置数据)图片、提示词上传、附件读取和持久引用折叠。 | +| `packages/client/ui-conversation` | 每个会话的草稿图片、附件栏、用户与助手图片控件和原图预览。 | +| `packages/ui/acp` | 图片块的明确兜底渲染。 | + +附件包(package)构成一个能力服务边界的接口与实现侧。输入区行为留在会话对象层,提供方转换留在适配器中,无需修改 `agent-loop`。 + +### 交付 + +1. 交付附件服务边界、角色无关的图片块、图片感知的 token 估算、Pi-AI 输入转换、DeepSeek 拒绝和宿主侧的持久化顺序。 +2. 交付 Web 上传与读取协议、内存草稿图片、支持粘贴与拖放的附件栏、用户与助手历史图片渲染、双击预览,以及组装后无需密钥的 Web 覆盖。 +3. 在输入区能够一致获取当前模型选择后,增加接收图片时立即提供的能力反馈。 +4. 分别为文件选择、通用文件与 PDF、音频与视频、持久草稿暂存、输出提供方认证和按引用感知的垃圾回收提出方案。 + +预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 + +## 曾考虑的替代方案 + +### 将每张粘贴图片保留在 `/var` 或其他临时目录中 + +临时存储适合在发送前使用,也适合通过操作系统接收剪贴板文件的原生客户端。但它不适合在消息被接受后继续使用:清理不在 harness 的控制范围内,路径因宿主而异,恢复或 fork 后的会话也可能比文件存在得更久。提案允许临时暂存,但会在追加事件前将已接受的字节复制进 `DSH_HOME`。 + +### 粘贴或拖放后立即持久化 + +立即持久化可以让草稿在重载后继续存在,但会在会话或消息持有对象前就创建持久对象,因此必须定义配额、遗留对象生命周期和清理策略。第一版保持未发送草稿为临时状态,并把发送被接受作为持久性边界。 + +### 在消息与会话日志中内联 base64 + +这种方式会在 RPC、事件、历史分页、fork、压缩(compaction)和浏览器存储中复制二进制数据,还会诱使 token 计量把编码文本当成模型文本。单一不可变对象配合小型引用,可以让持久表示保持有界。 + +### 使用浏览器对象 URL、本地路径或提供方 URL 作为规范内容 + +对象 URL 会随文档失效,本地路径不可移植,提供方 URL 则可能过期、跟踪查看者或暴露凭据。它们只能作为临时传输或预览细节存在。 + +### 用一个通用 `AttachmentBlock` 表示图片、文件、音频和视频 + +输入区展示可以使用通用附件栏,但提供方语义取决于具体模态。图片是原生多模态输入;PDF 可能是提供方文件或提取后的文本;视频可能由模型原生支持、抽帧处理或不受支持。特定的 `ImageBlock` 会迫使每个消费方明确处理或拒绝该模态。 + +### 依赖 UI 能力检查或静默过滤图片 + +UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模型的路径。静默过滤会改变用户意图。提供方强制检查仍是必需项,UI 检查则是可选的提前反馈。 + +## 验收标准 + +- 在两种输入区中粘贴或拖放一张或多张受支持的图片时,文本框上方会按顺序显示可移除缩略图,且不更改文本框内容;拖入时会高亮目标,放置不支持的文件也不会触发页面跳转,仅图片的发送可正常工作。 +- 未发送的浏览器图片仅以 `File` 与对象 URL 的形式存在,可以在内存中跨会话切换保留,不进入 `localStorage`,并在移除、发送被接受或服务释放后撤销。 +- 每张已接受的用户图片都会提交到解析所得 `DSH_HOME` 下,之后才会追加相应的 `user/message` 事件。事件只包含 `ImageBlock` 引用,绝不包含 base64 或临时路径。 +- 结构化助手图片只能由持久 `ImageBlock` 表示;未来的输出适配器必须在发出助手事件前持久化字节,而 Markdown 图片 URL 仍是文本。 +- 冷启动历史记录通过同一个有界控件渲染用户与助手图片引用。双击打开原图;Escape、激活背景区域和激活关闭控件可以关闭预览,且不提供自定义上下文菜单。 +- 除非同一个会话日志引用了该标识符,否则会话附件读取会失败。对象缺失或损坏会明确失败,绝不返回未经校验的字节。 +- Pi-AI 会为兼容路径生成提供方原生输入图片。DeepSeek 与所有未实现该能力的消费方返回明确的不支持内容错误,而不是丢弃该块。 +- 明确仅支持文本的当前模型会在持久化附件或追加会话事件前拒绝图片发送;未知元数据仍会到达适配器强制检查,发送失败则恢复草稿。 +- 无需密钥的单元测试、宿主集成测试、客户端集成测试和组装应用的 Chromium 覆盖会验证持久化顺序、日志中不含 base64、授权、粘贴与拖放、仅图片发送、历史用户与助手图片、原图预览和对象 URL 清理。 +- 当前生产适配器集合声明仅支持文本输出;输出提供方认证、文件选择、非图片文件、视频、持久草稿和垃圾回收不在第一版范围内。 + +## 风险 + +- 持久存储会在没有垃圾回收时持续增长。第一版选择回放安全,而不是过早删除。 +- 对象缺失或损坏会让模型请求无法精确重建。明确失败可以保持完整性,但在修复前可能阻止该会话继续运行。 +- JSON-RPC base64 会增加上传内存,并带来约三分之一的编码开销。第一版的限制可以约束开销;更大的媒体需要流式传输或二进制传输协议。 +- 未发送图片无法跨重载保留。持久草稿需要配额和遗留对象清理,而不是隐式复用消息存储。 +- 原图预览解码的像素多于行内控件显示的像素。像素限制、一次只打开一个预览和对象 URL 释放可以约束但无法消除浏览器瞬时内存占用。 +- 能力元数据可能缺失或陈旧。宿主前置检查可以改善反馈,适配器强制检查仍是权威结果。 +- 未来输出提供方可能需要经过身份认证的下载,助手图片才能完成,这会增加延迟与新的故障点。先持久化再追加事件的顺序优先保障回放完整性。 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index b75716c615..e3ee2badd6 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -3,8 +3,8 @@ // chromium. First describe: manifest injection + fail-loud half. Second // describe: the settled success pass — five REAL tsdown bundles (the // infrastructure four + layout) load through the DI chain in ?fixture mode -// and the three-column frame appears in one flip. The full conversation -// round lands in smoke-real under the W5 real-host standard. +// and the three-column frame appears in one flip. The full eight-plugin pass +// also exercises durable history images without a model key. import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -17,21 +17,24 @@ import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './suppo const bundlePath = (dir: string): string => fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url)) -/** id ↔ bundle table for the success pass (immediately four + layout). */ +/** id ↔ bundle table for the success pass (the production Web plugin chain). */ const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] /** Manifest served by the fake registry: one live bundle row, one missing row. */ const ROWS: WebPluginBootEntry[] = [ - { id: '@deepseek-ai/dsh-client-ui-layout', url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js', inject: [] }, + { id: '@deepseek-ai/dsh-client-ui-theme', url: '/plugins/@deepseek-ai/dsh-client-ui-theme/client.js', inject: [] }, { id: '@probe/absent', url: '/plugins/@probe/absent/client.js', inject: [] }, ] -const LAYOUT_BUNDLE = bundlePath('ui-layout') +const LIVE_BUNDLE = bundlePath('ui-theme') describe('web boot chain (keyless, real carrier)', () => { let server: Awaited> @@ -50,7 +53,7 @@ describe('web boot chain (keyless, real carrier)', () => { apiHandler, webPlugins: { snapshot: () => ROWS, - clientPath: id => (id === ROWS[0]!.id ? LAYOUT_BUNDLE : undefined), + clientPath: id => (id === ROWS[0]!.id ? LIVE_BUNDLE : undefined), }, }, (err) => { pageErrors.push(`server: ${String(err)}`) }) browser = await chromium.launch() @@ -91,7 +94,7 @@ describe('web boot chain (keyless, real carrier)', () => { }) }) -describe('web boot chain success pass (keyless, five real bundles, ?fixture)', () => { +describe('web boot chain success pass (keyless, production plugin chain, ?fixture)', () => { const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir))) let server: Awaited> let browser: Browser @@ -143,6 +146,88 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', ( expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout') }) + it('renders historical user and assistant images and opens the original on double-click', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-fixture-images')) + await page.getByRole('treeitem', { name: /fixture 3 sessions/ }).click() + await page.locator('[role="treeitem"][aria-selected]').first().click() + await page.waitForSelector('text=历史用户图片', { timeout: 15_000 }) + await page.waitForSelector('text=结构化模型图片', { timeout: 15_000 }) + const images = page.getByTitle('双击查看原图') + await expect.poll(() => images.count()).toBeGreaterThanOrEqual(2) + const first = images.first() + const box = await first.boundingBox() + expect(box?.width).toBeLessThanOrEqual(240) + expect(box?.height).toBeLessThanOrEqual(240) + await first.dblclick() + const preview = page.getByRole('dialog', { name: '原图预览' }) + await preview.waitFor({ state: 'visible' }) + await page.keyboard.press('Escape') + await preview.waitFor({ state: 'detached' }) + }) + + it('pastes and drops images into the composer, then sends them as durable history', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-fixture-image-paste')) + await page.getByRole('button', { name: '停止' }).click() + const textarea = page.locator('textarea') + await textarea.waitFor({ state: 'visible' }) + await expect.poll(() => textarea.isEnabled()).toBe(true) + await textarea.evaluate((element) => { + const binary = atob('iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==') + const bytes = Uint8Array.from(binary, character => character.charCodeAt(0)) + const transfer = new DataTransfer() + transfer.items.add(new File([bytes], 'clipboard.png', { type: 'image/png' })) + element.dispatchEvent(new ClipboardEvent('paste', { + bubbles: true, + cancelable: true, + clipboardData: transfer, + })) + }) + + const rail = page.getByLabel('待发送图片') + await rail.waitFor({ state: 'visible' }) + const draftImage = rail.getByTitle('双击查看原图') + await draftImage.dblclick() + const preview = page.getByRole('dialog', { name: '原图预览' }) + await preview.waitFor({ state: 'visible' }) + await page.keyboard.press('Escape') + await preview.waitFor({ state: 'detached' }) + + await page.getByRole('button', { name: '发送' }).click() + await rail.waitFor({ state: 'detached' }) + await expect.poll(() => page.getByTitle('双击查看原图').count()).toBeGreaterThanOrEqual(3) + + await page.getByRole('button', { name: '停止' }).click() + await expect.poll(() => textarea.isEnabled()).toBe(true) + await textarea.evaluate((element) => { + const binary = atob('iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==') + const bytes = Uint8Array.from(binary, character => character.charCodeAt(0)) + const transfer = new DataTransfer() + transfer.items.add(new File([bytes], 'dropped.png', { type: 'image/png' })) + element.closest('[class*="card"]')?.dispatchEvent(new DragEvent('dragenter', { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + })) + }) + await page.getByRole('status').filter({ hasText: '松开以添加图片' }).waitFor({ state: 'visible' }) + await textarea.evaluate((element) => { + const binary = atob('iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==') + const bytes = Uint8Array.from(binary, character => character.charCodeAt(0)) + const transfer = new DataTransfer() + transfer.items.add(new File([bytes], 'dropped.png', { type: 'image/png' })) + element.closest('[class*="card"]')?.dispatchEvent(new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + })) + }) + await rail.waitFor({ state: 'visible' }) + await rail.getByAltText('dropped.png').waitFor({ state: 'visible' }) + await page.getByRole('button', { name: '发送' }).click() + await rail.waitFor({ state: 'detached' }) + await expect.poll(() => page.getByTitle('双击查看原图').count()).toBeGreaterThanOrEqual(4) + }) + it('stayed clean: no page errors across the whole load chain', () => { expect(pageErrors).toEqual([]) }) diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 54951fd9b4..1abae21813 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -7,10 +7,14 @@ A service can be a core spine service, a swappable capability seam, or a bundle/ ```mermaid flowchart LR + pkg_attachment["attachment"] + svc_attachments["ctx.attachments
Durable binary attachment storage"] + pkg_attachment_local["attachment-local"] + pkg_host_runtime["host-runtime"] + pkg_llm_pi_ai["llm-pi-ai"] pkg_llm["llm"] svc_llm["ctx.llm
LLM adapter registry"] pkg_llm_deepseek["llm-deepseek"] - pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_replay["llm-replay"] pkg_agent_loop["agent-loop"] pkg_compact_basic["compact-basic"] @@ -125,6 +129,8 @@ flowchart LR pkg_agent --> svc_agents pkg_agent_loop --> svc_agentLoop pkg_approval --> svc_approval + pkg_attachment --> svc_attachments + pkg_attachment_local --> svc_attachments pkg_bash --> svc_bash pkg_bash_local --> svc_bash pkg_bash_sandbox --> svc_bash @@ -189,6 +195,8 @@ flowchart LR svc_agents --> pkg_tui_demo svc_approval --> pkg_tool_bash svc_approval --> pkg_tools + svc_attachments --> pkg_host_runtime + svc_attachments --> pkg_llm_pi_ai svc_bash --> pkg_hooks_claude svc_bash --> pkg_hooks_codex svc_bash --> pkg_tool_bash @@ -263,6 +271,7 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | +| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`host-runtime`](../packages/host/runtime), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4d2c803de0..ccd25885a8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -193,6 +193,26 @@ Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfi Source: [`packages/examples/agent-spine-demo/src/index.ts:87`](../packages/examples/agent-spine-demo/src/index.ts) +## `@deepseek-ai/dsh-attachment-local` + +```ts config-catalog +/** Local attachment backend configuration. */ +export interface Config { + /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ + dshHome?: string + /** Maximum encoded bytes accepted for one image. */ + maxImageBytes?: number + /** Maximum image count accepted in one submitted message. */ + maxImagesPerMessage?: number + /** Maximum aggregate encoded image bytes accepted in one submitted message. */ + maxMessageImageBytes?: number + /** Maximum intrinsic width multiplied by height accepted for one image. */ + maxImagePixels?: number +} +``` + +Source: [`packages/attachment/attachment-local/src/index.ts:26`](../packages/attachment/attachment-local/src/index.ts) + ## `@deepseek-ai/dsh-bash-local` ```ts config-catalog @@ -1881,6 +1901,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). +- `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-bash` — abstract `BashExecutor` ([`packages/bash/bash/src/index.ts`](../packages/bash/bash/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0fcb2fc28e..51b6aea5a1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -248,6 +248,30 @@ Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalReques Source: [`packages/ui/user-approval/src/index.ts:213`](../../packages/ui/user-approval/src/index.ts) +## `ctx.attachments` — `AttachmentStore` (abstract seam) + +Immutable binary attachment service. Implementations validate bytes before publishing a reference. + +```ts cordis-catalog +/** + * Validate and durably commit one image before its owning session event is appended. + * @param input - encoded bytes, declared media type, and optional display name. + * @returns a durable content-addressed reference. + */ +abstract saveImage(input: SaveImageAttachment): Promise + +/** + * Read one image and verify that bytes still match the recorded reference. + * @param ref - durable reference from the session log. + * @returns the verified bytes and canonical reference. + */ +abstract readImage(ref: ImageAttachmentRef): Promise +``` + +Types: [ImageAttachmentRef](../core-data-structures/attachment.md) · [SaveImageAttachment](../core-data-structures/attachment.md) · [StoredImageAttachment](../core-data-structures/attachment.md) + +Source: [`packages/attachment/attachment/src/index.ts:28`](../../packages/attachment/attachment/src/index.ts) + ## `ctx.bash` — `BashExecutor` (abstract seam) Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.bash` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). @@ -1493,7 +1517,7 @@ estimateMessage(message: Message): number Types: [EpochHeader](../core-data-structures/session.md) · [Message](../core-data-structures/core.md) · [Session](../core-data-structures/session.md) · [TokenMeasurement](../core-data-structures/token-meter.md) -Source: [`packages/llm/token-meter/src/index.ts:82`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts:87`](../../packages/llm/token-meter/src/index.ts) ## `ctx.toolResultPrune` — `ToolResultPruneService` diff --git a/docs/core-data-structures/attachment.md b/docs/core-data-structures/attachment.md new file mode 100644 index 0000000000..03af4a557d --- /dev/null +++ b/docs/core-data-structures/attachment.md @@ -0,0 +1,70 @@ +# Durable Image Attachments + +The attachment seam separates binary image ownership from the session log. A producer gives validated encoded bytes to [`ctx.attachments`](../cordis-catalog/services.md#ctxattachments); the service publishes an immutable content-addressed reference only after the object is durable. Session events and model-visible `ImageBlock`s contain that reference and metadata, never a browser object URL, host temporary path, provider URL, or base64 payload. + +Unsent browser drafts may stay in memory and native clients may stage them in operating-system temporary storage. Once the host accepts a user message, its images move below `/attachments/v1` before the user event is appended. Structured model image output follows the same persist-before-event rule. + +Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachment/attachment/src/types.ts) + +## Identity and verified metadata + +`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. + +```ts type-equiv +/** Raster image formats accepted by the version-one attachment path. */ +type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' +``` + +```ts type-equiv +/** Durable, serializable metadata for one immutable image object. */ +interface ImageAttachmentRef { + /** Opaque storage identifier; never a filesystem path or bearer URL. */ + attachmentId: AttachmentId + /** Media type verified from the stored bytes. */ + mediaType: ImageMediaType + /** Exact encoded byte length. */ + bytes: number + /** Intrinsic encoded width in pixels. */ + width: number + /** Intrinsic encoded height in pixels. */ + height: number + /** Optional display name stripped of local path information. */ + name?: string +} +``` + +```ts type-equiv +/** Deployment-resolved limits shared by upload consumers and UI preflight. */ +interface ImageAttachmentLimits { + maxImageBytes: number + maxImagesPerMessage: number + maxMessageImageBytes: number + maxImagePixels: number + mediaTypes: readonly ImageMediaType[] +} +``` + +The reference records intrinsic dimensions and encoded length so clients can lay out history without decoding first, while every authoritative read still re-checks digest, media signature, dimensions, and metadata against the object. + +## Commit and verified-read payloads + +```ts type-equiv +/** Request to validate and durably commit one image. */ +interface SaveImageAttachment { + data: Uint8Array + /** Caller-declared media type, checked against magic bytes. */ + mediaType: ImageMediaType + /** Optional browser/provider display name; it is never interpreted as a path. */ + name?: string +} +``` + +```ts type-equiv +/** Stored image bytes returned after reference and digest verification. */ +interface StoredImageAttachment { + ref: ImageAttachmentRef + data: Uint8Array +} +``` + +`saveImage()` validates bytes and atomically commits one object before returning its reference. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6b5596b782..a65eb2056e 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -28,6 +28,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, and the guarded execution pipeline | | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts | +| [attachment.md](attachment.md) | durable image identity and metadata, validation inputs, verified reads, and the `AttachmentStore` seam | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles | | [pty.md](pty.md) | persistent terminal ids, backend/session contracts, send readiness, bounded reads, and owner-visible snapshots | | [sandbox.md](sandbox.md) | per-session policy resolution and the process-confinement seam: file-effect modes, execution/provider policies, `ConfinedArgv`, enforcement and fail-closed errors | @@ -106,12 +107,13 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock + 'image': ImageBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } ``` -The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the merge-extensible map together with the adapter/UI/compaction support that honors it. +The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md) plus optional alternative text), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. A `Message` is a role plus blocks. Loop-derived assistant messages carry their durable provider/model identity and optional adapter-private replay metadata: @@ -192,6 +194,10 @@ interface LlmModelInfo { name: string /** Optional user-facing distinction from otherwise similar models. */ description?: string + /** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */ + inputModalities?: readonly ModelModality[] + /** Structured response modalities; absent means unknown, while an explicit omission is negative capability. */ + outputModalities?: readonly ModelModality[] } ``` diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 257ce90cda..f3b82801a2 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -208,6 +208,7 @@ declare abstract class LlmAdapter { interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock + 'image': ImageBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } diff --git a/knip.json b/knip.json index 3a7a443718..3dd84847d9 100644 --- a/knip.json +++ b/knip.json @@ -197,6 +197,11 @@ "src/**/*.ts" ] }, + "packages/attachment/attachment": { + "project": [ + "src/**/*.ts" + ] + }, "packages/util/timeout": { "entry": [ "tests/**/*.spec.ts" diff --git a/packages/README.md b/packages/README.md index b138ea0b65..46a0478654 100644 --- a/packages/README.md +++ b/packages/README.md @@ -24,6 +24,7 @@ Packages live at `packages///`; groups are containers, while names r | [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface | | [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface | | [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | +| [`attachment/`](attachment/README.md) | Durable attachment seam and DSH_HOME backend | Product — stable surface | | [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface | | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface | | [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface | diff --git a/packages/attachment/README.md b/packages/attachment/README.md new file mode 100644 index 0000000000..de975aa2f1 --- /dev/null +++ b/packages/attachment/README.md @@ -0,0 +1,10 @@ +# attachment/ - durable attachment capability family + +The durable binary attachment seam and its local filesystem implementation. Both are product packages. + +| Package | Role | ctx key | +|---|---|---| +| `attachment/` | Immutable attachment references, image limits, and storage service | `ctx.attachments` | +| `attachment-local/` | Content-addressed private storage below `DSH_HOME` | (registers on `ctx.attachments`) | + +Unsent browser drafts are intentionally outside this capability. Bytes enter durable storage only when a user prompt is submitted or when a provider adapter commits structured model output. diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md new file mode 100644 index 0000000000..21883bc9ff --- /dev/null +++ b/packages/attachment/attachment-local/README.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-attachment-local + +The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Writes use a private staging directory, owner-only files, a synced temporary file, and an atomic exclusive hard-link publish; reads re-check the digest, media signature, dimensions, and logged metadata. + +`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. + +## Model Experience + +Indirectly, through durable replay of historical user images and structured model image output after restart and fork. + +#### KV Cache effect + +None beyond the image block owned by the requesting adapter. + +## Known Limitations and Deferred Work + +- Objects are retained indefinitely; reference-aware garbage collection is deferred. +- The local backend assumes the host and provider adapter share this filesystem service. +- Animated GIF metadata is validated from the logical screen; frame-level decoding policy is provider-owned. diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json new file mode 100644 index 0000000000..b87fd83e79 --- /dev/null +++ b/packages/attachment/attachment-local/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-attachment-local", + "description": "Private content-addressed DSH_HOME attachment storage", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src"], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-attachment": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { "schemastery": "^3.18.0" }, + "devDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/attachment/attachment-local/src/image.ts b/packages/attachment/attachment-local/src/image.ts new file mode 100644 index 0000000000..59ddb4ebda --- /dev/null +++ b/packages/attachment/attachment-local/src/image.ts @@ -0,0 +1,101 @@ +/** Minimal raster header validation used before bytes enter durable storage. */ + +import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' + +/** Decoded metadata from a supported image header. */ +export interface DetectedImage { + mediaType: ImageMediaType + width: number + height: number +} + +function ascii(data: Uint8Array, start: number, value: string): boolean { + if (data.length < start + value.length) return false + for (let i = 0; i < value.length; i++) if (data[start + i] !== value.charCodeAt(i)) return false + return true +} + +function u16be(data: Uint8Array, offset: number): number { + return ((data[offset] ?? 0) << 8) | (data[offset + 1] ?? 0) +} + +function u16le(data: Uint8Array, offset: number): number { + return (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8) +} + +function u24le(data: Uint8Array, offset: number): number { + return (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8) | ((data[offset + 2] ?? 0) << 16) +} + +function u32be(data: Uint8Array, offset: number): number { + return (((data[offset] ?? 0) * 0x1000000) + ((data[offset + 1] ?? 0) << 16) + + ((data[offset + 2] ?? 0) << 8) + (data[offset + 3] ?? 0)) >>> 0 +} + +function u32le(data: Uint8Array, offset: number): number { + return ((data[offset] ?? 0) + ((data[offset + 1] ?? 0) << 8) + + ((data[offset + 2] ?? 0) << 16) + ((data[offset + 3] ?? 0) * 0x1000000)) >>> 0 +} + +function dimensions(width: number, height: number, mediaType: ImageMediaType): DetectedImage { + if (width < 1 || height < 1) throw new AttachmentError('Image dimensions must be positive.', 'INVALID_IMAGE') + return { mediaType, width, height } +} + +function jpeg(data: Uint8Array): DetectedImage | null { + if (data[0] !== 0xff || data[1] !== 0xd8) return null + const sof = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]) + let offset = 2 + while (offset + 3 < data.length) { + while (data[offset] === 0xff) offset++ + const marker = data[offset] + if (marker === undefined || marker === 0xd9 || marker === 0xda) break + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + offset++ + continue + } + const length = u16be(data, offset + 1) + if (length < 2 || offset + 1 + length > data.length) throw new AttachmentError('JPEG data is truncated.', 'INVALID_IMAGE') + if (sof.has(marker)) { + if (length < 7) throw new AttachmentError('JPEG dimensions are truncated.', 'INVALID_IMAGE') + return dimensions(u16be(data, offset + 6), u16be(data, offset + 4), 'image/jpeg') + } + offset += length + 1 + } + throw new AttachmentError('JPEG dimensions are missing.', 'INVALID_IMAGE') +} + +/** + * Detect a supported raster type and intrinsic dimensions from encoded bytes. + * @param data - complete encoded image bytes. + * @returns verified format and dimensions. + */ +export function detectImage(data: Uint8Array): DetectedImage { + if (data.length >= 24 + && data[0] === 0x89 && ascii(data, 1, 'PNG\r\n\u001a\n') && ascii(data, 12, 'IHDR')) { + return dimensions(u32be(data, 16), u32be(data, 20), 'image/png') + } + if (data.length >= 10 && (ascii(data, 0, 'GIF87a') || ascii(data, 0, 'GIF89a'))) { + return dimensions(u16le(data, 6), u16le(data, 8), 'image/gif') + } + const detectedJpeg = jpeg(data) + if (detectedJpeg !== null) return detectedJpeg + if (data.length >= 30 && ascii(data, 0, 'RIFF') && ascii(data, 8, 'WEBP')) { + const declaredLength = u32le(data, 4) + 8 + if (declaredLength > data.length) throw new AttachmentError('WebP data is truncated.', 'INVALID_IMAGE') + if (ascii(data, 12, 'VP8X')) return dimensions(u24le(data, 24) + 1, u24le(data, 27) + 1, 'image/webp') + if (ascii(data, 12, 'VP8L') && data[20] === 0x2f) { + const b0 = data[21] ?? 0 + const b1 = data[22] ?? 0 + const b2 = data[23] ?? 0 + const b3 = data[24] ?? 0 + return dimensions(1 + b0 + ((b1 & 0x3f) << 8), 1 + (b1 >> 6) + (b2 << 2) + ((b3 & 0x0f) << 10), 'image/webp') + } + if (ascii(data, 12, 'VP8 ') && data[23] === 0x9d && data[24] === 0x01 && data[25] === 0x2a) { + return dimensions(u16le(data, 26) & 0x3fff, u16le(data, 28) & 0x3fff, 'image/webp') + } + throw new AttachmentError('WebP dimensions are missing.', 'INVALID_IMAGE') + } + throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE') +} diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts new file mode 100644 index 0000000000..1c8e923867 --- /dev/null +++ b/packages/attachment/attachment-local/src/index.ts @@ -0,0 +1,74 @@ +/** Local durable attachment backend rooted below `DSH_HOME`. @module @deepseek-ai/dsh-attachment-local */ + +import { join, resolve } from 'node:path' +import { Context } from 'cordis' +import z from 'schemastery' +import { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { readImageFile, saveImageFile } from './store.ts' + +export { detectImage } from './image.ts' +export { readImageFile, saveImageFile } from './store.ts' +export { AttachmentError } from '@deepseek-ai/dsh-attachment' +export type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' + +/** Default maximum encoded bytes for one image. */ +export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024 +/** Default maximum images in one prompt. */ +export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 10 +/** Default maximum aggregate image bytes in one prompt. */ +export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 20 * 1024 * 1024 +/** Default maximum intrinsic pixels for one image. */ +export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000 + +/** Local attachment backend configuration. */ +export interface Config { + /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ + dshHome?: string + /** Maximum encoded bytes accepted for one image. */ + maxImageBytes?: number + /** Maximum image count accepted in one submitted message. */ + maxImagesPerMessage?: number + /** Maximum aggregate encoded image bytes accepted in one submitted message. */ + maxMessageImageBytes?: number + /** Maximum intrinsic width multiplied by height accepted for one image. */ + maxImagePixels?: number +} + +/** Persistent content-addressed local attachment store. */ +export class LocalAttachmentStore extends AttachmentStore { + static Config: z = z.object({ + dshHome: z.string(), + maxImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_BYTES), + maxImagesPerMessage: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_MESSAGE), + maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES), + maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS), + }) + + /** Absolute versioned storage root. */ + readonly root: string + readonly imageLimits: ImageAttachmentLimits + + constructor(ctx: Context, config: Config) { + super(ctx) + this.root = resolve(join(resolveDshHome(config.dshHome), 'attachments', 'v1')) + this.imageLimits = Object.freeze({ + maxImageBytes: config.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES, + maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE, + maxMessageImageBytes: config.maxMessageImageBytes ?? DEFAULT_MAX_MESSAGE_IMAGE_BYTES, + maxImagePixels: config.maxImagePixels ?? DEFAULT_MAX_IMAGE_PIXELS, + mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const), + }) + } + + async saveImage(input: SaveImageAttachment): Promise { + return saveImageFile(this.root, input, this.imageLimits) + } + + async readImage(ref: ImageAttachmentRef): Promise { + return readImageFile(this.root, ref, this.imageLimits) + } +} + +export default LocalAttachmentStore diff --git a/packages/attachment/attachment-local/src/invariant.ts b/packages/attachment/attachment-local/src/invariant.ts new file mode 100644 index 0000000000..eb14a84af6 --- /dev/null +++ b/packages/attachment/attachment-local/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion for `@deepseek-ai/dsh-attachment-local`. @module @deepseek-ai/dsh-attachment-local/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-attachment-local' +/** Cordis companion plugin name. */ +export const name = 'attachment-local-invariant' +/** Services required before package ownership can be reserved. */ +export const inject = ['invariants', 'attachments'] +/** No runtime invariant: immutable writes and verified reads are enforced directly at the backend boundary. */ +const install: InvariantInstaller = () => {} +/** + * Register the package invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the registration disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts new file mode 100644 index 0000000000..24fa3645be --- /dev/null +++ b/packages/attachment/attachment-local/src/store.ts @@ -0,0 +1,121 @@ +/** Content-addressed, owner-private local attachment storage. */ + +import { createHash, randomUUID } from 'node:crypto' +import { constants } from 'node:fs' +import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { + AttachmentError, + AttachmentId, +} from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' +import { detectImage } from './image.ts' + +const ID_PATTERN = /^sha256:([a-f0-9]{64})$/ + +function digest(data: Uint8Array): string { + return createHash('sha256').update(data).digest('hex') +} + +function displayName(value: string | undefined): string | undefined { + if (value === undefined) return undefined + const clean = basename(value).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255) + return clean === '' ? undefined : clean +} + +function objectPath(root: string, sha256: string): string { + return join(root, 'objects', sha256.slice(0, 2), sha256) +} + +function ensureReference(ref: ImageAttachmentRef): string { + const match = ID_PATTERN.exec(String(ref.attachmentId)) + if (match?.[1] === undefined) throw new AttachmentError('Attachment reference is invalid.', 'INVALID_ATTACHMENT_REF') + return match[1] +} + +function validateMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], limits: ImageAttachmentLimits): Omit { + if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') + if (data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') + const detected = detectImage(data) + if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH') + if (detected.width * detected.height > limits.maxImagePixels) throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS') + return { ...detected, bytes: data.byteLength } +} + +/** + * Save and verify immutable image bytes below a versioned attachment root. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param input - encoded bytes and declared metadata. + * @param limits - resolved storage policy. + * @returns durable content-addressed reference. + */ +export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise { + const metadata = validateMetadata(input.data, input.mediaType, limits) + const sha256 = digest(input.data) + const bucket = join(root, 'objects', sha256.slice(0, 2)) + const staging = join(root, 'tmp') + await mkdir(bucket, { recursive: true, mode: 0o700 }) + await mkdir(staging, { recursive: true, mode: 0o700 }) + await chmod(bucket, 0o700) + await chmod(staging, 0o700) + const temporary = join(staging, randomUUID()) + const target = objectPath(root, sha256) + let handle + try { + handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) + await handle.writeFile(input.data) + await handle.sync() + await handle.close() + handle = undefined + try { + await link(temporary, target) + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error + const existing = new Uint8Array(await readFile(target)) + if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') + } + await unlink(temporary) + } catch (error) { + if (handle !== undefined) await handle.close().catch(() => { /* close failure is superseded by the storage failure */ }) + await unlink(temporary).catch((cleanupError: unknown) => { + if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError + }) + if (error instanceof AttachmentError) throw error + throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) + } + const name = displayName(input.name) + return { + attachmentId: AttachmentId(`sha256:${sha256}`), + ...metadata, + ...(name !== undefined ? { name } : {}), + } +} + +/** + * Read and verify one content-addressed image. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param ref - reference recorded in the session log. + * @param limits - resolved storage policy. + * @returns verified bytes and reference. + */ +export async function readImageFile(root: string, ref: ImageAttachmentRef, limits: ImageAttachmentLimits): Promise { + const sha256 = ensureReference(ref) + let data: Uint8Array + try { + data = new Uint8Array(await readFile(objectPath(root, sha256))) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND') + throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error }) + } + if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') + const metadata = validateMetadata(data, ref.mediaType, limits) + if (metadata.bytes !== ref.bytes || metadata.width !== ref.width || metadata.height !== ref.height) { + throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT') + } + return { ref, data } +} diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts new file mode 100644 index 0000000000..9fff7d651e --- /dev/null +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -0,0 +1,96 @@ +import { createHash } from 'node:crypto' +import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { afterEach, describe, expect, it } from 'vitest' +import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' +import { readImageFile, saveImageFile } from '../src/store.ts' + +const PNG = Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +)) + +const LIMITS: ImageAttachmentLimits = { + maxImageBytes: 1024, + maxImagesPerMessage: 2, + maxMessageImageBytes: 2048, + maxImagePixels: 16, + mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], +} + +const roots: string[] = [] + +async function root(): Promise { + const value = await mkdtemp(join(tmpdir(), 'dsh-attachment-')) + roots.push(value) + return join(value, 'attachments', 'v1') +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))) +}) + +describe('local attachment store', () => { + it('publishes one private content-addressed object and deduplicates equal bytes', async () => { + const storageRoot = await root() + const first = await saveImageFile(storageRoot, { + data: PNG, mediaType: 'image/png', name: '/private/tmp/pixel.png', + }, LIMITS) + const second = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const sha256 = createHash('sha256').update(PNG).digest('hex') + const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) + + expect(first).toEqual({ + attachmentId: `sha256:${sha256}`, + mediaType: 'image/png', + bytes: PNG.byteLength, + width: 1, + height: 1, + name: 'pixel.png', + }) + expect(second.attachmentId).toBe(first.attachmentId) + expect(new Uint8Array(await readFile(object))).toEqual(PNG) + expect((await stat(object)).mode & 0o777).toBe(0o600) + expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700) + await expect(readImageFile(storageRoot, first, LIMITS)).resolves.toEqual({ ref: first, data: PNG }) + }) + + it('rejects malformed bytes, mismatched declarations, byte limits, and decoded-pixel limits', async () => { + const storageRoot = await root() + await expect(saveImageFile(storageRoot, { + data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', + }, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + await expect(saveImageFile(storageRoot, { + data: PNG, mediaType: 'image/jpeg', + }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TYPE_MISMATCH' }) + await expect(saveImageFile(storageRoot, { + data: PNG, mediaType: 'image/png', + }, { ...LIMITS, maxImageBytes: 1 })).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) + + const wide = PNG.slice() + wide.set([0, 0, 0, 5, 0, 0, 0, 5], 16) + await expect(saveImageFile(storageRoot, { + data: wide, mediaType: 'image/png', + }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) + }) + + it('fails closed when an object is missing, corrupted, or addressed by an invalid reference', async () => { + const storageRoot = await root() + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const sha256 = String(ref.attachmentId).slice('sha256:'.length) + const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) + await chmod(object, 0o600) + await writeFile(object, Uint8Array.of(1, 2, 3)) + await expect(readImageFile(storageRoot, ref, LIMITS)) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + await expect(readImageFile(storageRoot, { ...ref, attachmentId: 'bad' as never }, LIMITS)) + .rejects.toMatchObject({ code: 'INVALID_ATTACHMENT_REF' }) + + const missingRoot = await root() + await mkdir(missingRoot, { recursive: true }) + await expect(readImageFile(missingRoot, ref, LIMITS)) + .rejects.toMatchObject({ code: 'ATTACHMENT_NOT_FOUND' }) + }) +}) diff --git a/packages/attachment/attachment-local/tsconfig.json b/packages/attachment/attachment-local/tsconfig.json new file mode 100644 index 0000000000..528649e3a4 --- /dev/null +++ b/packages/attachment/attachment-local/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "lib/types" }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../attachment" }, + { "path": "../../util/paths" }, + { "path": "../../support/invariants" } + ] +} diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md new file mode 100644 index 0000000000..2fcc99c477 --- /dev/null +++ b/packages/attachment/attachment/README.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-attachment + +The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. + +Unsent composer images remain browser-owned temporary drafts. `saveImage` is called only at message submission or while committing structured provider output, before any model-visible session event is published. `readImage` verifies the content-addressed object against its logged metadata. + +## Model Experience + +Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference. + +#### KV Cache effect + +Adding an image changes the provider request and therefore invalidates the affected request suffix. + +## Known Limitations and Deferred Work + +- Version one accepts PNG, JPEG, WebP, and GIF only. +- Retention and garbage collection are deferred because resumed and forked sessions may share immutable objects. +- Generic files, audio, video, and persistent unsent drafts require separate lifecycle and provider contracts. diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json new file mode 100644 index 0000000000..1b8394774e --- /dev/null +++ b/packages/attachment/attachment/package.json @@ -0,0 +1,27 @@ +{ + "name": "@deepseek-ai/dsh-attachment", + "description": "Durable immutable attachment storage seam for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src"], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts new file mode 100644 index 0000000000..000856be78 --- /dev/null +++ b/packages/attachment/attachment/src/index.ts @@ -0,0 +1,51 @@ +/** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */ + +import { Context, Service } from 'cordis' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from './types.ts' + +export { AttachmentError, AttachmentId } from './types.ts' +export type { + AttachmentId as AttachmentIdType, + ImageAttachmentLimits, + ImageAttachmentRef, + ImageMediaType, + SaveImageAttachment, + StoredImageAttachment, +} from './types.ts' + +declare module 'cordis' { + interface Context { + attachments: AttachmentStore + } +} + +/** Immutable binary attachment service. Implementations validate bytes before publishing a reference. */ +export abstract class AttachmentStore extends Service { + constructor(ctx: Context) { + super(ctx, 'attachments') + } + + /** Deployment-resolved image policy used by authoritative and fast-path validation. */ + abstract readonly imageLimits: ImageAttachmentLimits + + /** + * Validate and durably commit one image before its owning session event is appended. + * @param input - encoded bytes, declared media type, and optional display name. + * @returns a durable content-addressed reference. + */ + abstract saveImage(input: SaveImageAttachment): Promise + + /** + * Read one image and verify that bytes still match the recorded reference. + * @param ref - durable reference from the session log. + * @returns the verified bytes and canonical reference. + */ + abstract readImage(ref: ImageAttachmentRef): Promise +} + +export default AttachmentStore diff --git a/packages/attachment/attachment/src/invariant.ts b/packages/attachment/attachment/src/invariant.ts new file mode 100644 index 0000000000..2c00d56ece --- /dev/null +++ b/packages/attachment/attachment/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion for `@deepseek-ai/dsh-attachment`. @module @deepseek-ai/dsh-attachment/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-attachment' +/** Cordis companion plugin name. */ +export const name = 'attachment-invariant' +/** Service required before package ownership can be reserved. */ +export const inject = ['invariants'] +/** No runtime invariant: this stateless seam owns types while implementations enforce immutable-store checks. */ +const install: InvariantInstaller = () => {} +/** + * Register the package invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the registration disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts new file mode 100644 index 0000000000..88b1dceb52 --- /dev/null +++ b/packages/attachment/attachment/src/types.ts @@ -0,0 +1,75 @@ +/** Durable attachment vocabulary. @module @deepseek-ai/dsh-attachment/types */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Opaque content-addressed identifier for one immutable attachment object. */ +export type AttachmentId = Branded<'AttachmentId'> + +/** + * Brand a validated storage identifier. + * @param value - backend-produced opaque identifier. + * @returns the branded identifier. + */ +export function AttachmentId(value: string): AttachmentId { + return value as AttachmentId +} + +/** Raster image formats accepted by the version-one attachment path. */ +export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + +/** Durable, serializable metadata for one immutable image object. */ +export interface ImageAttachmentRef { + /** Opaque storage identifier; never a filesystem path or bearer URL. */ + attachmentId: AttachmentId + /** Media type verified from the stored bytes. */ + mediaType: ImageMediaType + /** Exact encoded byte length. */ + bytes: number + /** Intrinsic encoded width in pixels. */ + width: number + /** Intrinsic encoded height in pixels. */ + height: number + /** Optional display name stripped of local path information. */ + name?: string +} + +/** Deployment-resolved limits shared by upload consumers and UI preflight. */ +export interface ImageAttachmentLimits { + maxImageBytes: number + maxImagesPerMessage: number + maxMessageImageBytes: number + maxImagePixels: number + mediaTypes: readonly ImageMediaType[] +} + +/** Request to validate and durably commit one image. */ +export interface SaveImageAttachment { + data: Uint8Array + /** Caller-declared media type, checked against magic bytes. */ + mediaType: ImageMediaType + /** Optional browser/provider display name; it is never interpreted as a path. */ + name?: string +} + +/** Stored image bytes returned after reference and digest verification. */ +export interface StoredImageAttachment { + ref: ImageAttachmentRef + data: Uint8Array +} + +/** Stable failures suitable for host RPC error mapping. */ +export class AttachmentError extends Error { + /** Stable machine-routing failure code. */ + readonly code: string + + /** + * @param message - human-readable failure description without raw bytes or host paths. + * @param code - stable machine-routing code. + * @param options - optional chained cause. + */ + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, options) + this.name = 'AttachmentError' + this.code = code + } +} diff --git a/packages/attachment/attachment/tsconfig.json b/packages/attachment/attachment/tsconfig.json new file mode 100644 index 0000000000..e9edd57b9e --- /dev/null +++ b/packages/attachment/attachment/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "lib/types" }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../util/brand" }, + { "path": "../../support/invariants" } + ] +} diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 778b431b71..c83100542b 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -29,6 +29,7 @@ }, "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 9ea6ba6dfe..cf4c9c2ac3 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -6,7 +6,7 @@ // The ./api and ./client subpath exports are the browser-safe channels added for this. export type { - ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, + ApiProxy, SessionsApi, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 7d4a93e888..ab0c45129d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -6,6 +6,7 @@ // approval (placeholder-card material, subscribed-baseline-replay semantics: stable rpcId reuse). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, @@ -28,6 +29,16 @@ function sid(id: string): SessionId { return id as SessionId } +const FIXTURE_IMAGE_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==' +const FIXTURE_IMAGE_REF: ImageAttachmentRef = { + attachmentId: 'fixture:image' as AttachmentIdType, + mediaType: 'image/png', + bytes: 68, + width: 160, + height: 90, + name: 'fixture-image.png', +} + /** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50), * mixing reasoning blocks / tool call+result / steering / context. */ function buildAlphaLog(): SessionEvent[] { @@ -88,6 +99,12 @@ function buildAlphaLog(): SessionEvent[] { toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt') toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt') toolTurn(62, 'fx-note', '{"note":"三型卡验收样本"}', '已记录') + push({ type: 'turn/start', data: { turn: 63, trigger: { kind: 'message', source: { kind: 'user' } } } }) + push({ type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')], source: { kind: 'user' } } }) + push({ type: 'step/start', data: { turn: 63, step: 0 } }) + push({ type: 'assistant/message', surfaceOp: 'append', data: { turn: 63, step: 0, content: [...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }], provenance: { provider: 'fixture', model: 'fx-vision' } } }) + push({ type: 'step/end', data: { turn: 63, step: 0 } }) + push({ type: 'turn/end', data: { turn: 63, reason: { kind: 'completed' } } }) return events as unknown as SessionEvent[] } @@ -186,6 +203,18 @@ function pageOf( return { events, hasMore: start > 0 } } +/** Fixture mirror of host session-scoped attachment authorization. */ +function logReferencesAttachment(log: readonly SessionEvent[], attachmentId: string): boolean { + const visit = (value: unknown): boolean => { + if (Array.isArray(value)) return value.some(visit) + if (typeof value !== 'object' || value === null) return false + const record = value as Record + if (record.attachmentId === attachmentId) return true + return Object.values(record).some(visit) + } + return log.some(event => visit(event.data)) +} + interface StreamConn { push(envelope: RpcRequest): void } @@ -243,7 +272,11 @@ export function createFixtureApi(): ApiProxy { { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' }, ] const logs = new Map([[sid('fx-alpha'), buildAlphaLog()]]) - const nextTurn = new Map([[sid('fx-alpha'), 60]]) + const nextTurn = new Map([[sid('fx-alpha'), 64]]) + const attachments = new Map([[ + String(FIXTURE_IMAGE_REF.attachmentId), + { attachment: FIXTURE_IMAGE_REF, data: FIXTURE_IMAGE_DATA }, + ]]) let nextSession = 1 let nextRpc = 1 const mint = (): ReturnType => RpcId(`fx-rpc-${nextRpc++}`) @@ -394,21 +427,44 @@ export function createFixtureApi(): ApiProxy { } summary.updatedAt = Date.now() const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('') + const durable: ContentBlock[] = content.map((block) => { + if (block.type === 'text') return block + const attachment: ImageAttachmentRef = { + attachmentId: `fixture:${crypto.randomUUID()}` as AttachmentIdType, + mediaType: block.mediaType, + bytes: Math.max(1, Math.floor(block.data.length * 3 / 4) - (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0)), + width: 160, + height: 90, + ...block.name === undefined ? {} : { name: block.name }, + } + attachments.set(String(attachment.attachmentId), { attachment, data: block.data }) + return { type: 'image', attachment } + }) if (mode === 'steer' && replays.has(id)) { // Steering: insert a steering message into the current turn; the replay continues. /* v8 ignore next -- the ?? arm needs a missing counter, but a live replay implies a prior prompt already set it. */ const turn = (nextTurn.get(id) ?? 1) - 1 - append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, content, source: { kind: 'user' } } }) + append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, content: durable, source: { kind: 'user' } } }) return ok(request, { accepted: true as const }) } const turn = nextTurn.get(id) ?? 0 nextTurn.set(id, turn + 1) setRunning(id, true) append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) - append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } }) + append(id, { type: 'user/message', surfaceOp: 'append', data: { content: durable, source: { kind: 'user' } } }) startReply(id, turn, `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`) return ok(request, { accepted: true as const }) }, + attachment: (request) => { + const stored = attachments.get(String(request.payload.attachmentId)) + if (stored === undefined) { + return err(request, { code: 'attachment-error', message: 'fixture attachment missing', details: { reason: 'ATTACHMENT_NOT_FOUND' } }) + } + if (!logReferencesAttachment(logs.get(request.payload.sessionId) ?? [], String(request.payload.attachmentId))) { + return err(request, { code: 'attachment-error', message: 'fixture attachment is not referenced by this session', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }) + } + return ok(request, stored) + }, cancel: (request) => { const replay = replays.get(request.payload.sessionId) if (replay !== undefined) { @@ -421,7 +477,24 @@ export function createFixtureApi(): ApiProxy { }, }, host: { - describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }), + describe: request => ok(request, { + version: '0.0.0-fixture', + cwd: '/tmp/fixture', + provider: 'fixture', + model: 'fx-vision', + activeModel: { + provider: 'fixture', id: 'fx-vision', name: 'Fixture Vision', + inputModalities: ['text', 'image'], outputModalities: ['text', 'image'], + }, + imageLimits: { + maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 10, + maxMessageImageBytes: 20 * 1024 * 1024, + maxImagePixels: 40_000_000, + mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], + }, + attachedSessions: 1, + }), }, events: { async *mux(_request, signal) { @@ -512,6 +585,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.create': return this.api.sessions.create(request) case 'session.history': return this.api.sessions.history(request) case 'session.prompt': return this.api.sessions.prompt(request) + case 'session.attachment': return this.api.sessions.attachment(request) case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index b017d1c9e2..fd6c5c3345 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -14,7 +14,7 @@ import { WebApiClient } from './web-api-client.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { - ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, + ApiProxy, SessionsApi, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ToolCallView, ToolResultView, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index af5743bf9a..3bb964b7a5 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -48,6 +48,8 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ events: [], hasMore: false })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onAttachment: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) @@ -64,6 +66,7 @@ export class FakeApiClient implements IApiClient { history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => this.record('session.history', payload, this.onHistory(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), + attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), } diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index c50921a44d..6b81f4ef95 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -167,7 +167,7 @@ describe('createFixtureApi', () => { expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics) }) - it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { + it('steer with no replay in flight promotes image bytes to a session-scoped reference', async () => { const api = createFixtureApi() const abort = new AbortController() const framesPromise = collect(api.events.mux(req({}), abort.signal), abort, @@ -175,14 +175,36 @@ describe('createFixtureApi', () => { await new Promise(resolve => setTimeout(resolve, 10)) const created = await api.sessions.create(req({})) if (!created.result.ok) throw new Error('create failed') - // steer while idle + a non-text content block (covers the '' arm of the text join). + // steer while idle + an image: the fixture mirrors the host's durable send boundary. await api.sessions.prompt(req({ sessionId: created.result.value.sessionId, mode: 'steer' as const, - content: [{ type: 'text' as const, text: '短' }, { type: 'image', data: 'x' } as never], + content: [{ type: 'text' as const, text: '短' }, { + type: 'image' as const, + mediaType: 'image/png' as const, + data: 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==', + name: 'pixel.png', + }], })) const frames = await framesPromise const types = frames.filter((f): f is Extract => f.type === 'session/event').map(f => f.event.type) expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not a steering insert + const user = frames.find((f): f is Extract => + f.type === 'session/event' && f.event.type === 'user/message') + const image = ((user?.event.data as { content?: { type: string; attachment?: { attachmentId: never } }[] } | undefined)?.content) + ?.find(block => block.type === 'image') + expect(image?.attachment).toBeDefined() + if (image?.attachment === undefined) throw new Error('fixture image missing') + const loaded = await api.sessions.attachment(req({ + sessionId: created.result.value.sessionId, + attachmentId: image.attachment.attachmentId, + })) + expect(loaded.result).toMatchObject({ ok: true, value: { attachment: { name: 'pixel.png' } } }) + const denied = await api.sessions.attachment(req({ + sessionId: sid('fx-beta'), attachmentId: image.attachment.attachmentId, + })) + expect(denied.result).toMatchObject({ + ok: false, error: { details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }, + }) }) it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => { @@ -306,6 +328,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { const id = created.result.value.sessionId expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true) expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) + expect((await client.sessions.attachment({ sessionId: sid('fx-alpha'), attachmentId: 'fixture:image' as never })).result.ok).toBe(true) expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) }) diff --git a/packages/client/connection/tsconfig.json b/packages/client/connection/tsconfig.json index 8b0357cf97..5308ef7241 100644 --- a/packages/client/connection/tsconfig.json +++ b/packages/client/connection/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../attachment/attachment" + }, { "path": "../../llm/llm" }, diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 384b2e2e3c..2d4ece6bbb 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -35,6 +35,7 @@ }, "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 18c5501972..10b5391faf 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,6 +4,7 @@ // string here (narrow to real brands when convenient). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' /** Assistant content blocks sorted by what the UI cares about @@ -11,6 +12,7 @@ import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@ export type AssistantBlock = | { kind: 'text'; text: string } | { kind: 'reasoning'; text: string } + | { kind: 'image'; attachment: ImageAttachmentRef; alt?: string } | { kind: 'tool-call'; callId: string; name: string; argsRaw: string } | { kind: 'other'; block: unknown } @@ -32,6 +34,10 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock { switch (block.type) { case 'text': return { kind: 'text', text: block.text } case 'reasoning': return { kind: 'reasoning', text: block.text } + case 'image': return { + kind: 'image', attachment: block.attachment, + ...block.alt === undefined ? {} : { alt: block.alt }, + } case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments } default: return { kind: 'other', block } } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 761aca96c2..53cac6ecc9 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -3,9 +3,9 @@ // created, they keep consuming mux frames in the background; React connects directly via // subscribe/getSnapshot. -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client' +import type { HistoryEntry, IApiClient, MuxFrame, PromptContentPart, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client' import { transportError } from '@deepseek-ai/dsh-client-connection/client' import type { ObservableSnapshot } from '../contract/store.ts' import type { @@ -82,11 +82,11 @@ export class Session implements ObservableSnapshot { /** * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. - * @param content - core content blocks verbatim. + * @param content - text plus browser-owned temporary image uploads. * @param mode - queue appends after the current turn; steer interrupts it. * @returns the prompt result (also mirrored into promptError on failure). */ - async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise> { + async prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise> { this.promptError = null this.lastAgentError = null this.notifier.markDirty() @@ -103,6 +103,23 @@ export class Session implements ObservableSnapshot { return result } + /** + * Resolve one image referenced by this session into browser-consumable bytes. + * @param attachmentId - opaque id found in the folded session log. + * @returns the authenticated reference and decoded bytes. + */ + async readAttachment(attachmentId: AttachmentIdType): Promise> { + try { + const result = (await this.api.sessions.attachment({ sessionId: this.sessionId, attachmentId })).result + if (!result.ok) return result + const binary = atob(result.value.data) + const data = Uint8Array.from(binary, char => char.charCodeAt(0)) + return { ok: true, value: { attachment: result.value.attachment, data } } + } catch (error) { + return transportError(error) + } + } + /** * Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot). * @returns the cancel result. diff --git a/packages/client/runtime/tests/conversation.spec.ts b/packages/client/runtime/tests/conversation.spec.ts index 7da20bc6f3..345f21b598 100644 --- a/packages/client/runtime/tests/conversation.spec.ts +++ b/packages/client/runtime/tests/conversation.spec.ts @@ -1,22 +1,30 @@ /** Assistant block classifier (moved here with sessions/conversation.ts). */ import { describe, expect, it } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client' import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts' describe('toAssistantBlock', () => { it('classifies the four block shapes', () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 68, + width: 1, + height: 1, + } const blocks: ContentBlock[] = [ { type: 'text', text: '正文' }, { type: 'reasoning', text: '思考' }, { type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' } as ContentBlock, - { type: 'image', data: 'x' } as unknown as ContentBlock, + { type: 'image', attachment }, ] expect(toAssistantBlocks(blocks)).toEqual([ { kind: 'text', text: '正文' }, { kind: 'reasoning', text: '思考' }, { kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{}' }, - { kind: 'other', block: blocks[3] }, + { kind: 'image', attachment }, ]) expect(toAssistantBlock(blocks[0] as ContentBlock)).toEqual({ kind: 'text', text: '正文' }) }) diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index c13ef09fcb..e8d2c43571 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -51,6 +51,8 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ events: [], hasMore: false })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onAttachment: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) @@ -67,6 +69,7 @@ export class FakeApiClient implements IApiClient { history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => this.record('session.history', payload, this.onHistory(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), + attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), } diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 149f73c1fc..84db7c73cf 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -239,6 +239,21 @@ describe('prompt and cancel errors', () => { expect(result.ok).toBe(false) expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } }) }) + + it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => { + const { api, session } = makeSession() + const result = await session.readAttachment('attachment-1' as never) + expect(result).toEqual({ + ok: true, + value: { + attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + data: Uint8Array.of(0), + }, + }) + expect(api.callsOf('session.attachment')).toEqual([{ + sessionId: SID, attachmentId: 'attachment-1', + }]) + }) }) describe('pending interactions', () => { diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index 9584ecc857..28ace18be3 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../attachment/attachment" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 51bdffb2d6..0dc65934f3 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -34,6 +34,7 @@ }, "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-i18n": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 266602e1b2..8087c42ebb 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -12,7 +12,9 @@ import type { SessionId, SessionsService, SlotsService } from '@deepseek-ai/dsh- import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client' import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client' import type { SelectionTarget } from './contract/views.ts' -import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts' +import type { + ComposerAttachment, ConversationInjected, DetailsInjected, EmptyStateInjected, +} from './contract/slots.ts' import { createChatStore } from './stores.ts' import { ConversationService } from './service.ts' import { ToolViewRegistry } from './toolviews/registry.ts' @@ -92,15 +94,25 @@ export function apply(ctx: Context): void { subscribe: fn => conversation.subscribeViews(fn), version: () => conversation.viewsVersion(), }, - send: (text, mode) => { + addImages: (files) => { + const images = conversation.createDraftImages(files) + actions.addImages(images.map(image => image.id)) + }, + removeImage: (id) => { + conversation.releaseDraftImage(id) + actions.removeImage(id) + }, + draftImages: ids => conversation.draftImages(ids), + send: (text, images: readonly ComposerAttachment[], mode) => { const trimmed = text.trim() - if (trimmed === '') return + if (trimmed === '' && images.length === 0) return // Optimistic clear with failure restore (choreography lives with the // sender; the business failure also lands in snapshot.promptError). - // The store write path stays inside the declared actions set: - // restoreDraft itself no-ops once the user typed something new. + // The store write path stays inside the declared actions set. actions.clearDraft() - void scoped.send(trimmed, mode).catch(() => { actions.restoreDraft(trimmed) }) + void scoped.send(trimmed, mode, images.map(image => image.file)) + .then(() => { conversation.releaseDraftImages(images) }) + .catch(() => { actions.restoreDraft(trimmed, images.map(image => image.id)) }) }, stop: () => { scoped.cancel().catch(() => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 61ac069b1c..cc2875add8 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -8,6 +8,7 @@ import { memo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { IconThinkOutline14, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' import { ToolRow } from './ToolRow.tsx' +import { ImageGallery, type ImageLoader } from './MessageImage.tsx' import css from './AssistantMarkdown.module.css' export interface AssistantMarkdownProps { @@ -15,6 +16,7 @@ export interface AssistantMarkdownProps { streaming: boolean /** Frozen partial of an aborted turn: rendered with a 已停止 marker, no pulse. */ interrupted?: boolean | undefined + loadImage?: ImageLoader } function firstLine(text: string): string { @@ -36,14 +38,17 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { ) } -export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) { +export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted, loadImage = unavailableImage }: AssistantMarkdownProps) { const last = blocks.length - 1 + const images = blocks.filter((block): block is Extract => block.kind === 'image') return (
+ {blocks.map((block, i) => { switch (block.kind) { case 'text': return case 'reasoning': return + case 'image': return null // Tool-call heads render as tool rows in the chat view's grouping pass. case 'tool-call': return null default: return @@ -54,3 +59,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
) }) + +function unavailableImage(): Promise { + return Promise.reject(new Error('图片读取服务不可用')) +} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b444e557ec..82379c7448 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -11,8 +11,9 @@ // map but only rows whose own selected bit flipped. import { - memo, useLayoutEffect, useMemo, useRef, useState, type FC, type ReactNode, + memo, useCallback, useLayoutEffect, useMemo, useRef, useState, type FC, type ReactNode, } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' @@ -24,6 +25,7 @@ import type { ToolViewResolver } from '../contract/toolview.ts' import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { MessageItem } from './MessageItem.tsx' +import type { ImageLoader } from './MessageImage.tsx' import { PendingCard } from './PendingCard.tsx' import { ToolViewOutlet } from './ToolViewOutlet.tsx' import css from './ChatView.module.css' @@ -32,6 +34,7 @@ import css from './ChatView.module.css' export interface ChatViewDeps { toolviews: ToolViewResolver t: Translate + resolveImage?(sessionId: SessionId, attachment: ImageAttachmentRef): Promise } const FOLLOW_THRESHOLD = 24 @@ -102,16 +105,17 @@ const ToolGroup = memo(function ToolGroup({ registry, sessionId, useSession, t, /** The streaming partial, isolated so chunk batches re-render only this tail. * onGrow lets the scroll owner follow content the parent never re-renders for. */ -function StreamingTail({ useSession, onGrow }: { +function StreamingTail({ useSession, onGrow, loadImage }: { useSession: UseConversation onGrow: () => void + loadImage: ImageLoader }) { const partial = useSession((s) => s.partial) useLayoutEffect(() => { onGrow() }) if (partial === null) return null - return + return } /** @@ -120,7 +124,7 @@ function StreamingTail({ useSession, onGrow }: { * @returns the ConvViewProps component registered as the chat view. */ export function createChatView(deps: ChatViewDeps): FC { - const { toolviews, t } = deps + const { toolviews, t, resolveImage = unavailableImage } = deps return function ChatView({ sessionId, useSession: useSessionWide, useStore, actions }: ConvViewProps) { const useSession = useSessionWide as UseConversation @@ -132,6 +136,10 @@ export function createChatView(deps: ChatViewDeps): FC { const hasMore = useSession((s) => s.hasMore) const loadingOlder = useSession((s) => s.loadingOlder) const selectedCallId = useStore((s) => s.selection?.callId) + const loadImage = useCallback( + attachment => resolveImage(sessionId, attachment), + [resolveImage, sessionId], + ) const items = useMemo(() => deriveChatFlow(nodes), [nodes]) @@ -229,11 +237,11 @@ export function createChatView(deps: ChatViewDeps): FC { } const node: ConversationNode = item.node if (node.kind === 'assistant') { - return + return } /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ if (node.kind === 'tool-result') return null - return + return } return ( @@ -250,7 +258,7 @@ export function createChatView(deps: ChatViewDeps): FC { )} {items.map(renderItem)} - + {runningCalls.length > 0 && (
{runningCalls.map((call) => ( @@ -291,3 +299,7 @@ export function createChatView(deps: ChatViewDeps): FC { ) } } + +function unavailableImage(): Promise { + return Promise.reject(new Error('图片读取服务不可用')) +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageImage.module.css b/packages/client/ui-conversation/src/client/chat/MessageImage.module.css new file mode 100644 index 0000000000..e05a6fc625 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/MessageImage.module.css @@ -0,0 +1,53 @@ +.gallery { + display: flex; + flex-wrap: wrap; + gap: 8px; + width: min(240px, 100%); +} + +.gallery[data-align='end'] { + justify-content: flex-end; + align-self: flex-end; +} + +.gallery[data-align='start'] { + justify-content: flex-start; + align-self: flex-start; +} + +.frame { + display: grid; + flex: 0 0 auto; + place-items: center; + min-width: 44px; + min-height: 44px; + padding: 0; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 12px; + background: var(--dsw-alias-interactive-bg-hover); + cursor: zoom-in; +} + +.frame img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.loading, +.error { + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +.error { + max-width: 240px; + padding: 10px 12px; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 10px; + background: var(--dsw-alias-interactive-bg-hover-danger); + cursor: pointer; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageImage.tsx b/packages/client/ui-conversation/src/client/chat/MessageImage.tsx new file mode 100644 index 0000000000..b4dcd19afd --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/MessageImage.tsx @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { ImageLightbox } from '../skeleton/ImageLightbox.tsx' +import css from './MessageImage.module.css' + +/** Loads a session-authorized durable image URL. */ +export type ImageLoader = (attachment: ImageAttachmentRef) => Promise + +/** Compact history renderer with retryable loading and double-click original preview. */ +export function MessageImage({ attachment, alt, load }: { + attachment: ImageAttachmentRef + alt?: string + load: ImageLoader +}) { + const [src, setSrc] = useState(null) + const [error, setError] = useState(false) + const [open, setOpen] = useState(false) + const close = useCallback(() => { setOpen(false) }, []) + const size = useMemo(() => { + const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height) + return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) } + }, [attachment.height, attachment.width]) + + const request = useCallback(() => { + setError(false) + setSrc(null) + void load(attachment).then(setSrc).catch(() => { setError(true) }) + }, [attachment, load]) + + useEffect(() => { + let live = true + setError(false) + void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) }) + return () => { live = false } + }, [attachment, load]) + + const label = alt ?? attachment.name ?? '图片' + if (error) return + return ( + <> + + {open && src !== null && } + + ) +} + +/** Wrapping image group shared by user and assistant history. */ +export function ImageGallery({ images, load, align }: { + images: readonly { attachment: ImageAttachmentRef; alt?: string }[] + load: ImageLoader + align: 'start' | 'end' +}) { + if (images.length === 0) return null + return ( +
+ {images.map((image, index) => ( + + ))} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 50e560278d..732cb97202 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -7,9 +7,18 @@ justify-content: flex-end; } +.userStack { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + min-width: 0; + max-width: min(525px, 82%); +} + .bubble { /* 525px cap inside the 736 column; percentage keeps narrow windows sane. */ - max-width: min(525px, 82%); + max-width: 100%; background: var(--dsw-specific-bubble); border-radius: 22px; /* 44px single-line bubble: 24 line + 10 vertical padding each side. */ diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 4bfe07d687..d77beeb5b3 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -9,33 +9,49 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' import css from './MessageItem.module.css' +import { ImageGallery, type ImageLoader } from './MessageImage.tsx' export interface MessageItemProps { node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode + loadImage?: ImageLoader } -function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } { +type UserImage = Extract + +function contentParts(content: readonly unknown[]): { + text: string + images: { attachment: UserImage['attachment']; alt?: string }[] + rest: unknown[] +} { const texts: string[] = [] + const images: { attachment: UserImage['attachment']; alt?: string }[] = [] const rest: unknown[] = [] for (const block of content) { - const b = block as { type?: string; text?: string } + const b = block as { type?: string; text?: string; attachment?: unknown; alt?: string } if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text) + else if (b.type === 'image' && b.attachment !== undefined) { + const image = b as UserImage + images.push({ attachment: image.attachment, ...image.alt === undefined ? {} : { alt: image.alt } }) + } else rest.push(block) } - return { text: texts.join(''), rest } + return { text: texts.join(''), images, rest } } -export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) { +export const MessageItem = memo(function MessageItem({ node, loadImage = unavailableImage }: MessageItemProps) { switch (node.kind) { case 'user': case 'steering': { - const { text, rest } = contentText(node.content) + const { text, images, rest } = contentParts(node.content) return (
-
- {node.kind === 'steering' && 插话} - - {rest.map((block, i) => )} +
+ + {(text !== '' || rest.length > 0 || node.kind === 'steering') &&
+ {node.kind === 'steering' && 插话} + + {rest.map((block, i) => )} +
}
) @@ -54,3 +70,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) ) } }) + +function unavailableImage(): Promise { + return Promise.reject(new Error('图片读取服务不可用')) +} diff --git a/packages/client/ui-conversation/src/client/chat/register.ts b/packages/client/ui-conversation/src/client/chat/register.ts index b417ab4c0b..2796bffc15 100644 --- a/packages/client/ui-conversation/src/client/chat/register.ts +++ b/packages/client/ui-conversation/src/client/chat/register.ts @@ -46,7 +46,11 @@ export function registerChat(deps: RegisterChatDeps): () => void { id: 'chat', label: 'Chat', order: 0, - component: createChatView({ toolviews, t }), + component: createChatView({ + toolviews, + t, + resolveImage: (sessionId, attachment) => conversation.resolveImage(sessionId, attachment), + }), chrome: { footer: StatsLine }, }) } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a7001fb21e..80cb6f0b3f 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -12,6 +12,13 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { createChatStore } from '../stores.ts' import type { SelectionTarget, ViewEntry } from './views.ts' +/** Browser-owned image that has not crossed the durable host boundary. */ +export interface ComposerAttachment { + id: string + file: File + previewUrl: string +} + /** The shared chat store handle type (apply constructs one; conversation and details both declare it). */ export type ChatStore = ReturnType @@ -29,8 +36,14 @@ export interface ConversationInjected { subscribe(fn: () => void): () => void version(): number } + /** Create browser previews and append their ids through the declared store action. */ + addImages(files: readonly File[]): void + /** Release one browser preview and remove its id through the declared store action. */ + removeImage(id: string): void + /** Resolve ordered store ids to the browser-owned draft attachments still available this runtime. */ + draftImages(ids: readonly string[]): readonly ComposerAttachment[] /** Send choreography: trims, clears the draft optimistically, restores it on failure. */ - send(text: string, mode: 'queue' | 'steer'): void + send(text: string, images: readonly ComposerAttachment[], mode: 'queue' | 'steer'): void /** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */ stop(): void /** Selection write + details panel opening in one gesture (store action + layout orchestration). */ @@ -60,7 +73,12 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & /** Injected share of the no-session empty-state slot. */ export interface EmptyStateInjected { /** The create → navigate → first-send chain, in one service call. */ - startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise + startSession(opts: { + cwd?: string + text: string + images?: readonly File[] + mode: 'queue' | 'steer' + }): Promise } /** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */ diff --git a/packages/client/ui-conversation/src/client/contract/views.ts b/packages/client/ui-conversation/src/client/contract/views.ts index e201b67e20..f1fa865834 100644 --- a/packages/client/ui-conversation/src/client/contract/views.ts +++ b/packages/client/ui-conversation/src/client/contract/views.ts @@ -69,6 +69,12 @@ export interface ChatStoreState { selection: SelectionTarget | null /** Composer draft (persisted; survives session switches and reloads). */ draft: string + /** + * Ordered browser-draft attachment ids. The matching File/object-URL + * objects stay in ConversationService because they are runtime-only; stale + * persisted ids are pruned by ConversationRoot after a page reload. + */ + imageIds: string[] /** Active conversation view id; null falls back to the first registered view. */ view: ViewId | null } diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 9c9ee639b8..9fc167320c 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -22,8 +22,27 @@ import type { Context } from 'cordis' // SessionsService tags contexts with — scopeOf then always returns undefined // in the browser while unit tests (single-instance path resolution) stay green. import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client' -import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ViewEntry, ViewId } from './index.ts' +import type { ComposerAttachment } from './contract/slots.ts' + +/** Opaque wrapper keeps browser `File` internals outside persisted store state. */ +class BrowserDraftAttachment implements ComposerAttachment { + readonly id: string + readonly previewUrl: string + readonly #file: File + + constructor(file: File) { + this.id = crypto.randomUUID() + this.previewUrl = URL.createObjectURL(file) + this.#file = file + } + + get file(): File { + return this.#file + } +} /** Mutable view-registry cell (plain object: mutation never crosses the tracker proxy). */ interface ViewsState { @@ -36,6 +55,9 @@ interface ViewsState { /** Scope-addressed conversation service (root singleton, provided as `conversation`). */ export class ConversationService extends Service { + private readonly draftAttachments = new Map() + private readonly imageUrls = new Map>() + private readonly createdImageUrls = new Set() private readonly viewsState: ViewsState = { entries: new Map(), cache: null, tick: 0, listeners: new Set(), } @@ -46,6 +68,12 @@ export class ConversationService extends Service { */ constructor(ctx: Context) { super(ctx, 'conversation') + ctx.effect(() => () => { + for (const url of this.createdImageUrls) URL.revokeObjectURL(url) + this.createdImageUrls.clear() + this.draftAttachments.clear() + this.imageUrls.clear() + }, 'conversation attachment URL cache') } /** @@ -54,13 +82,98 @@ export class ConversationService extends Service { * exists for caller choreography (the composer restores the draft on it). * @param text - prompt text, sent verbatim as one text block. * @param mode - queue after the current turn, or steer into it. + * @param images - browser-owned temporary images promoted by the host during this call. */ - async send(text: string, mode: 'queue' | 'steer'): Promise { + async send(text: string, mode: 'queue' | 'steer', images: readonly File[] = []): Promise { const session = this.scopedSession('send') - const result = await session.prompt([{ type: 'text', text }], mode) + const uploaded = await Promise.all(images.map(async file => ({ + type: 'image' as const, + mediaType: imageMediaType(file.type), + data: bytesToBase64(new Uint8Array(await file.arrayBuffer())), + ...(file.name === '' ? {} : { name: file.name }), + }))) + const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] + const result = await session.prompt(content, mode) if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`) } + /** + * Create runtime-only draft attachments and their object URLs. + * @param files - browser-owned image files. + * @returns ordered attachment descriptors whose ids may enter the chat store. + */ + createDraftImages(files: readonly File[]): readonly ComposerAttachment[] { + return files.map((file) => { + const attachment = new BrowserDraftAttachment(file) + this.draftAttachments.set(attachment.id, attachment) + this.createdImageUrls.add(attachment.previewUrl) + return attachment + }) + } + + /** + * Resolve ordered store ids to runtime-owned draft attachments. + * @param ids - ordered ids from the chat store. + * @returns attachments still available in this browser runtime. + */ + draftImages(ids: readonly string[]): readonly ComposerAttachment[] { + const attachments: ComposerAttachment[] = [] + for (const id of ids) { + const attachment = this.draftAttachments.get(id) + if (attachment !== undefined) attachments.push(attachment) + } + return attachments + } + + /** + * Release one draft attachment preview. + * @param id - draft-local attachment id. + */ + releaseDraftImage(id: string): void { + const attachment = this.draftAttachments.get(id) + if (attachment === undefined) return + this.draftAttachments.delete(id) + this.createdImageUrls.delete(attachment.previewUrl) + revokePreview(attachment.previewUrl) + } + + /** + * Release sent draft attachment previews. + * @param attachments - successfully submitted attachments. + */ + releaseDraftImages(attachments: readonly ComposerAttachment[]): void { + for (const attachment of attachments) this.releaseDraftImage(attachment.id) + } + + /** + * Resolve and cache one session-authorized historical image as an object URL. + * @param sessionId - session whose durable log grants the read. + * @param attachment - immutable reference from that log. + * @returns a browser URL for inline and original-size display. + */ + resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { + const key = `${sessionId}:${attachment.attachmentId}` + const cached = this.imageUrls.get(key) + if (cached !== undefined) return cached + const pending = this.requireSessions().manager.get(sessionId).readAttachment(attachment.attachmentId) + .then((result) => { + if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) + if (typeof URL.createObjectURL !== 'function') { + return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` + } + const bytes = Uint8Array.from(result.value.data) + const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType })) + this.createdImageUrls.add(url) + return url + }) + .catch((error: unknown) => { + this.imageUrls.delete(key) + throw error + }) + this.imageUrls.set(key, pending) + return pending + } + /** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */ async cancel(): Promise { const session = this.scopedSession('cancel') @@ -132,7 +245,12 @@ export class ConversationService extends Service { * awaited through the RPC round trip). * @param opts - project directory, prompt text, and send mode. */ - async startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise { + async startSession(opts: { + cwd?: string + text: string + images?: readonly File[] + mode: 'queue' | 'steer' + }): Promise { const sessions = this.requireSessions() const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd }) // The manager notifier flushes per microtask; one await guarantees the @@ -146,7 +264,7 @@ export class ConversationService extends Service { // global store and still binds this service to the scoped ctx. const scopedConversation = scoped.get('conversation') if (scopedConversation === undefined) throw new Error('conversation.startSession: conversation service unavailable through the new scope') - await scopedConversation.send(opts.text, opts.mode) + await scopedConversation.send(opts.text, opts.mode, opts.images ?? []) } /** Resolve the caller scope's Session or throw on root contexts. */ @@ -173,3 +291,28 @@ function bumpViews(state: ViewsState): void { state.tick += 1 for (const fn of [...state.listeners]) fn() } + +function imageMediaType(value: string): ImageMediaType { + switch (value) { + case 'image/png': + case 'image/jpeg': + case 'image/webp': + case 'image/gif': + return value + default: + throw new Error(`不支持的图片格式:${value || '未知格式'}`) + } +} + +function bytesToBase64(data: Uint8Array): string { + let binary = '' + const chunk = 0x8000 + for (let offset = 0; offset < data.length; offset += chunk) { + binary += String.fromCharCode(...data.subarray(offset, offset + chunk)) + } + return btoa(binary) +} + +function revokePreview(url: string): void { + if (url.startsWith('blob:')) URL.revokeObjectURL(url) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index de970381ff..d018d93457 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -5,7 +5,7 @@ // Breadcrumbs derive from useSessions with a pure parentId walk; the active // view id lives in the chat store's `view` field (per-session by store scope). -import { useMemo, useSyncExternalStore, type ReactNode } from 'react' +import { useEffect, useMemo, useSyncExternalStore, type ReactNode } from 'react' import clsx from 'clsx' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' @@ -36,7 +36,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationRoot({ sessionId, useSession, useSessions, useStore, actions, - views, send, stop, openDetails, loadOlder, open, + views, addImages, removeImage, draftImages, send, stop, openDetails, loadOlder, open, }: ConversationRootProps) { useSyncExternalStore(views.subscribe, views.version) const list = views.list() @@ -47,11 +47,22 @@ export function ConversationRoot({ const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual) const draft = useStore(s => s.draft) + const imageIds = useStore(s => s.imageIds) + const attachments = useMemo(() => draftImages(imageIds), [draftImages, imageIds]) const running = useSession(s => s.running) const removed = useSession(s => s.removed) const promptError = useSession(s => s.promptError) const turns = useSession(s => countTurns(s)) + // Browser File/object-URL values are intentionally runtime-only. A reload + // may rehydrate ids whose objects no longer exist; prune those ids through + // the declared store action after the first render. + useEffect(() => { + if (attachments.length !== imageIds.length) { + actions.pruneImages(attachments.map(attachment => attachment.id)) + } + }, [actions, attachments, imageIds]) + const error: InputBarError | null = promptError === null ? null : { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` } @@ -128,12 +139,15 @@ export function ConversationRoot({ { send(draft, mode) }} + onAddImages={addImages} + onRemoveAttachment={removeImage} + onSend={(mode) => { send(draft, attachments, mode) }} onStop={stop} />
diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx index 420ff7a622..e837154d2a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx @@ -6,10 +6,10 @@ // §6) plus a free-form new-directory input; submit runs the startSession // chain (create → open → send) in one service call. -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client' -import type { EmptyStateSlotProps } from '../contract/slots.ts' +import type { ComposerAttachment, EmptyStateSlotProps } from '../contract/slots.ts' import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' import css from './EmptyState.module.css' @@ -36,6 +36,9 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { // Local viewing state: the empty state owns no session, so its draft is // ephemeral by design (drafts are keyed by session id; there is none yet). const [draft, setDraft] = useState('') + const [attachments, setAttachments] = useState([]) + const attachmentsRef = useRef(attachments) + attachmentsRef.current = attachments const [cwd, setCwd] = useState('') const [custom, setCustom] = useState(false) const [sending, setSending] = useState(false) @@ -44,11 +47,16 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { const submit = (mode: 'queue' | 'steer'): void => { const text = draft.trim() /* v8 ignore next -- defensive: InputBar disables send while empty. */ - if (text === '' || sending) return + if ((text === '' && attachments.length === 0) || sending) return setSending(true) setError(null) const chosen = cwd.trim() - startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) }) + startSession({ + text, + ...(attachments.length === 0 ? {} : { images: attachments.map(item => item.file) }), + mode, + ...(chosen === '' ? {} : { cwd: chosen }), + }) .catch((reason: unknown) => { // The empty state survives failure with the draft intact (no session // exists to carry promptError; this is the only local error surface). @@ -58,6 +66,24 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { // Success needs no cleanup: the session selection swaps this slot out for the session body. } + useEffect(() => () => { + for (const attachment of attachmentsRef.current) URL.revokeObjectURL(attachment.previewUrl) + }, []) + + const addImages = (files: readonly File[]): void => { + setAttachments(current => [...current, ...files.map(file => ({ + id: crypto.randomUUID(), file, previewUrl: URL.createObjectURL(file), + }))]) + } + + const removeImage = (id: string): void => { + setAttachments((current) => { + const removed = current.find(item => item.id === id) + if (removed !== undefined) URL.revokeObjectURL(removed.previewUrl) + return current.filter(item => item.id !== id) + }) + } + const picker = (
{custom @@ -102,6 +128,7 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
{}} diff --git a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css new file mode 100644 index 0000000000..d6b96f9fd7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css @@ -0,0 +1,34 @@ +.backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 40px; + background: color-mix(in srgb, var(--dsw-alias-label-primary) 74%, transparent); +} + +.image { + max-width: min(100%, 1600px); + max-height: calc(100vh - 80px); + object-fit: contain; + border-radius: 12px; + background: var(--dsw-specific-input-major); + box-shadow: var(--dsw-shadow-lv3); +} + +.close { + position: fixed; + top: 20px; + right: 20px; + display: grid; + place-items: center; + width: 36px; + height: 36px; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 999px; + background: var(--dsw-specific-input-major); + color: var(--dsw-alias-label-primary); + font-size: 24px; + cursor: pointer; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx new file mode 100644 index 0000000000..5c53433713 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx @@ -0,0 +1,34 @@ +import { useEffect, useRef } from 'react' +import css from './ImageLightbox.module.css' + +/** Document-level original-image preview opened by an explicit double-click. */ +export function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; onClose(): void }) { + const closeRef = useRef(null) + const restoreRef = useRef(null) + + useEffect(() => { + restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null + closeRef.current?.focus() + const onKeyDown = (event: globalThis.KeyboardEvent): void => { + if (event.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + restoreRef.current?.focus() + } + }, [onClose]) + + return ( +
{ if (event.target === event.currentTarget) onClose() }} + > + {alt} + +
+ ) +} 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 1139c3c9c1..7194a54f07 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -32,6 +32,7 @@ } .card { + position: relative; display: flex; flex-direction: column; /* figma Input 34:11458: 12px between the text area and the button row. */ @@ -49,6 +50,25 @@ line-height: 24px; } +.dragActive { + border-color: var(--dsw-alias-state-business-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2); +} + +.dropHint { + position: absolute; + z-index: 2; + inset: 4px; + display: grid; + place-items: center; + border-radius: 16px; + background: color-mix(in srgb, var(--dsw-specific-input-major) 88%, var(--dsw-alias-state-business-primary)); + color: var(--dsw-alias-state-business-primary); + font-size: 14px; + font-weight: 600; + pointer-events: none; +} + /* New-session state rounds up (figma: r24 and a taller box). */ .hero .card { border-radius: 24px; @@ -61,6 +81,57 @@ padding: 10px 12px 0; } +.attachments { + display: flex; + gap: 8px; + min-width: 0; + padding: 12px 12px 0; + overflow-x: auto; + overflow-y: hidden; +} + +.attachment { + position: relative; + flex: 0 0 72px; + width: 72px; + height: 72px; +} + +.thumbnail { + width: 72px; + height: 72px; + padding: 0; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 12px; + background: var(--dsw-alias-interactive-bg-hover); + cursor: zoom-in; +} + +.thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.remove { + position: absolute; + top: -6px; + right: -6px; + display: grid; + place-items: center; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid var(--dsw-specific-input-major); + border-radius: 999px; + background: var(--dsw-alias-label-primary); + color: var(--dsw-specific-input-major); + font-size: 16px; + line-height: 1; + cursor: pointer; +} + /* Mirror-div auto-grow wrapper: the hidden mirror is in normal flow and sets the height (min 2 lines / max 14 lines); the textarea rides it absolutely. Mirror and textarea MUST share font, line-height, padding and wrapping rules or heights diverge. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index c5c8307ffc..eba7c52451 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -5,11 +5,19 @@ // LOCKS the input: textarea disabled with the draft visible, stop is the only // action; the turn ending re-enables and refocuses. -import { useEffect, useRef } from 'react' -import type { KeyboardEvent, MouseEvent, ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' +import type { ComposerAttachment } from '../contract/slots.ts' +import { ImageLightbox } from './ImageLightbox.tsx' import css from './InputBar.module.css' +const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']) + +function supportedImages(files: Iterable): File[] { + return [...files].filter(file => IMAGE_MEDIA_TYPES.has(file.type)) +} + /** Prompt failure surface (mirrors the session snapshot's promptError shape). */ export interface InputBarError { op: 'send' | 'stop' @@ -18,6 +26,7 @@ export interface InputBarError { export interface InputBarProps { draft: string + attachments?: readonly ComposerAttachment[] running: boolean disabled: boolean error: InputBarError | null @@ -27,15 +36,22 @@ export interface InputBarProps { /** Optional leading accessory row content (the empty state mounts its cwd picker here). */ accessory?: ReactNode onDraftChange: (text: string) => void + onAddImages?: (files: readonly File[]) => void + onRemoveAttachment?: (id: string) => void onSend: (mode: 'queue' | 'steer') => void onStop: () => void } export function InputBar({ - draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop, + draft, attachments = [], running, disabled, error, variant, placeholder, accessory, + onDraftChange, onAddImages = () => {}, onRemoveAttachment = () => {}, onSend, onStop, }: InputBarProps) { - const empty = draft.trim() === '' + const empty = draft.trim() === '' && attachments.length === 0 + const [preview, setPreview] = useState(null) + const [dragActive, setDragActive] = useState(false) + const [dropError, setDropError] = useState(null) const inputRef = useRef(null) + const dragDepthRef = useRef(0) // IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders; // clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend. const composingRef = useRef(false) @@ -72,6 +88,56 @@ export function InputBar({ if (!empty && !locked) onSend('queue') } + const onPaste = (event: ClipboardEvent): void => { + const files = [...event.clipboardData.items] + .filter(item => item.kind === 'file' && IMAGE_MEDIA_TYPES.has(item.type)) + .map(item => item.getAsFile()) + .filter((file): file is File => file !== null) + if (files.length === 0) return + event.preventDefault() + setDropError(null) + onAddImages(files) + } + + const onDragEnter = (event: DragEvent): void => { + if (!event.dataTransfer.types.includes('Files')) return + event.preventDefault() + if (locked) return + dragDepthRef.current += 1 + setDropError(null) + setDragActive(true) + } + + const onDragOver = (event: DragEvent): void => { + if (!event.dataTransfer.types.includes('Files')) return + event.preventDefault() + event.dataTransfer.dropEffect = locked ? 'none' : 'copy' + } + + const onDragLeave = (event: DragEvent): void => { + if (!event.dataTransfer.types.includes('Files') || locked) return + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) setDragActive(false) + } + + const onDrop = (event: DragEvent): void => { + if (!event.dataTransfer.types.includes('Files')) return + event.preventDefault() + dragDepthRef.current = 0 + setDragActive(false) + if (locked) return + const dropped = [...event.dataTransfer.files] + const images = supportedImages(dropped) + if (images.length === 0) { + setDropError('暂仅支持 PNG、JPEG、WebP 和 GIF 图片') + return + } + setDropError(images.length === dropped.length ? null : '已忽略不受支持的非图片文件') + onAddImages(images) + } + + const closePreview = useCallback(() => { setPreview(null) }, []) + // Button presses steal focus from the textarea; suppress at mousedown so typing continues seamlessly. const keepFocus = (e: MouseEvent): void => { e.preventDefault() @@ -95,8 +161,38 @@ export function InputBar({ {error.op === 'stop' ? '停止失败' : '发送失败'}:{error.message}
)} -
+ {dropError !== null &&
{dropError}
} +
+ {dragActive &&
松开以添加图片
} {accessory !== undefined &&
{accessory}
} + {attachments.length > 0 && ( +
+ {attachments.map(attachment => ( +
+ + +
+ ))} +
+ )} {/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper (min/max capped in CSS); the absolutely-positioned textarea rides its height. Counting rows by '\n' cannot see soft wraps. */} @@ -110,6 +206,7 @@ export function InputBar({ rows={2} onChange={(e) => onDraftChange(e.target.value)} onKeyDown={onKeyDown} + onPaste={onPaste} onCompositionStart={onCompositionStart} onCompositionEnd={onCompositionEnd} /> @@ -137,6 +234,7 @@ export function InputBar({
+ {preview !== null && } ) } diff --git a/packages/client/ui-conversation/src/client/stores.ts b/packages/client/ui-conversation/src/client/stores.ts index ed27290827..cb18637290 100644 --- a/packages/client/ui-conversation/src/client/stores.ts +++ b/packages/client/ui-conversation/src/client/stores.ts @@ -19,8 +19,11 @@ import type { ChatStoreState, SelectionTarget, ViewId } from './contract/views.t type ChatActions = { select: (draft: ChatStoreState, target: SelectionTarget | null) => void setDraft: (draft: ChatStoreState, text: string) => void + addImages: (draft: ChatStoreState, ids: readonly string[]) => void + removeImage: (draft: ChatStoreState, id: string) => void + pruneImages: (draft: ChatStoreState, available: readonly string[]) => void clearDraft: (draft: ChatStoreState) => void - restoreDraft: (draft: ChatStoreState, text: string) => void + restoreDraft: (draft: ChatStoreState, text: string, imageIds: readonly string[]) => void setView: (draft: ChatStoreState, view: ViewId) => void } @@ -37,15 +40,30 @@ export function createChatStore(): EngineStoreHandle, so init and the // contract cannot drift. - init: (): ChatStoreState => ({ selection: null, draft: '', view: null }), + init: (): ChatStoreState => ({ selection: null, draft: '', imageIds: [], view: null }), persist: 'dsh.conversation.chat', actions: { select: (d, target: SelectionTarget | null) => { d.selection = target }, setDraft: (d, text: string) => { d.draft = text }, - clearDraft: (d) => { d.draft = '' }, - // Optimistic-send failure restore: only when the user typed nothing new - // since the clear (send choreography lives in the inject factory). - restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text }, + addImages: (d, ids: readonly string[]) => { d.imageIds.push(...ids) }, + removeImage: (d, id: string) => { + d.imageIds = d.imageIds.filter(candidate => candidate !== id) + }, + pruneImages: (d, available: readonly string[]) => { + const keep = new Set(available) + d.imageIds = d.imageIds.filter(id => keep.has(id)) + }, + clearDraft: (d) => { + d.draft = '' + d.imageIds = [] + }, + // Optimistic-send failure restore keeps any newer typing/images while + // restoring the submitted draft material that disappeared on clear. + restoreDraft: (d, text: string, imageIds: readonly string[]) => { + if (d.draft === '') d.draft = text + const current = new Set(d.imageIds) + d.imageIds = [...imageIds.filter(id => !current.has(id)), ...d.imageIds] + }, setView: (d, view: ViewId) => { d.view = view }, }, }) diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 4631e08d77..b766f12afc 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -132,25 +132,25 @@ describe('conversation slot inject surface', () => { const { instance, injected } = b.conversationSurface(ROOT) // Whitespace-only: no send, and the (whitespace) draft is not cleared. instance.actions.setDraft(' ') - injected.send(' ', 'queue') + injected.send(' ', [], 'queue') expect(b.sessionFake.prompt).not.toHaveBeenCalled() expect(instance.store.getSnapshot().draft).toBe(' ') // Success: cleared and stays cleared. instance.actions.setDraft('hello') - injected.send('hello', 'queue') + injected.send('hello', [], 'queue') expect(instance.store.getSnapshot().draft).toBe('') await Promise.resolve() expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue') // Failure: restored (draft still empty when the rejection lands). b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } }) instance.actions.setDraft('retry me') - injected.send('retry me', 'queue') + injected.send('retry me', [], 'queue') await vi.waitFor(() => { expect(instance.store.getSnapshot().draft).toBe('retry me') }) // Failure landing after new typing: no clobber (restoreDraft fills empty only). b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } }) - injected.send('retry me', 'queue') + injected.send('retry me', [], 'queue') instance.actions.setDraft('typed during flight') await new Promise(r => setTimeout(r, 0)) expect(instance.store.getSnapshot().draft).toBe('typed during flight') diff --git a/packages/client/ui-conversation/tests/chat-store.spec.ts b/packages/client/ui-conversation/tests/chat-store.spec.ts index 5993662352..865d6b0f3f 100644 --- a/packages/client/ui-conversation/tests/chat-store.spec.ts +++ b/packages/client/ui-conversation/tests/chat-store.spec.ts @@ -15,9 +15,9 @@ beforeEach(() => { }) describe('createChatStore', () => { - it('init shape: empty selection/draft/view', () => { + it('init shape: empty selection/draft/images/view', () => { const store = createChatStore().create() - expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null }) + expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', imageIds: [], view: null }) }) it('actions cover the declared write set', () => { @@ -30,6 +30,11 @@ describe('createChatStore', () => { store.actions.setDraft('hello') expect(store.store.getSnapshot().draft).toBe('hello') + store.actions.addImages(['a', 'b']) + store.actions.removeImage('a') + expect(store.store.getSnapshot().imageIds).toEqual(['b']) + store.actions.pruneImages([]) + expect(store.store.getSnapshot().imageIds).toEqual([]) store.actions.clearDraft() expect(store.store.getSnapshot().draft).toBe('') @@ -40,12 +45,15 @@ describe('createChatStore', () => { it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => { const store = createChatStore().create() // Rollback path: draft was cleared by send, nothing typed since. - store.actions.restoreDraft('failed text') + store.actions.restoreDraft('failed text', ['old-image']) expect(store.store.getSnapshot().draft).toBe('failed text') + expect(store.store.getSnapshot().imageIds).toEqual(['old-image']) // The user typed something new before the failure landed: keep theirs. store.actions.setDraft('newer input') - store.actions.restoreDraft('stale text') + store.actions.addImages(['new-image']) + store.actions.restoreDraft('stale text', ['old-image']) expect(store.store.getSnapshot().draft).toBe('newer input') + expect(store.store.getSnapshot().imageIds).toEqual(['old-image', 'new-image']) }) it('persists per scope key and rehydrates a fresh instance', () => { diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 660127946b..eb03543781 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -129,3 +129,89 @@ describe('error strip and variants', () => { expect(view.container.querySelector('[class*="hero"]')).not.toBeNull() }) }) + +describe('image draft rail', () => { + it('collects supported clipboard images and leaves non-image clipboard data to the browser', () => { + const onAddImages = vi.fn() + const { textarea } = setup({ draft: '', onAddImages }) + const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' }) + const prevented = fireEvent.paste(textarea, { + clipboardData: { + items: [ + { kind: 'string', type: 'text/plain', getAsFile: () => null }, + { kind: 'file', type: 'image/png', getAsFile: () => image }, + ], + }, + }) + expect(prevented).toBe(false) + expect(onAddImages).toHaveBeenCalledWith([image]) + + fireEvent.paste(textarea, { + clipboardData: { items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => image }] }, + }) + expect(onAddImages).toHaveBeenCalledTimes(1) + }) + + it('accepts supported image drops, highlights the target, and prevents browser navigation', () => { + const onAddImages = vi.fn() + const { view } = setup({ draft: '', onAddImages }) + const card = view.container.querySelector('[class*="card"]')! + const image = new File([Uint8Array.of(1, 2, 3)], 'dropped.png', { type: 'image/png' }) + const dataTransfer = { + types: ['Files'], + files: [image], + dropEffect: 'none', + } + expect(fireEvent.dragEnter(card, { dataTransfer })).toBe(false) + expect(view.getByRole('status').textContent).toContain('松开以添加图片') + expect(fireEvent.dragOver(card, { dataTransfer })).toBe(false) + expect(dataTransfer.dropEffect).toBe('copy') + expect(fireEvent.drop(card, { dataTransfer })).toBe(false) + expect(view.queryByRole('status')).toBeNull() + expect(onAddImages).toHaveBeenCalledWith([image]) + }) + + it('ignores unsupported dropped files and refuses drops while locked', () => { + const onAddImages = vi.fn() + const { view } = setup({ draft: '', onAddImages }) + const card = view.container.querySelector('[class*="card"]')! + const documentFile = new File(['hello'], 'notes.txt', { type: 'text/plain' }) + fireEvent.drop(card, { + dataTransfer: { types: ['Files'], files: [documentFile], dropEffect: 'none' }, + }) + expect(view.getByText(/暂仅支持 PNG/)).toBeTruthy() + expect(onAddImages).not.toHaveBeenCalled() + + const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' }) + const locked = setup({ draft: '', disabled: true, onAddImages }) + const lockedCard = locked.view.container.querySelector('[class*="card"]')! + const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' } + fireEvent.dragEnter(lockedCard, { dataTransfer }) + expect(locked.view.queryByRole('status')).toBeNull() + fireEvent.dragOver(lockedCard, { dataTransfer }) + expect(dataTransfer.dropEffect).toBe('none') + fireEvent.drop(lockedCard, { dataTransfer }) + expect(onAddImages).not.toHaveBeenCalled() + }) + + it('allows image-only send, removes a thumbnail, and opens original preview on double-click', () => { + const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) + const attachment = { id: 'draft-1', file, previewUrl: 'blob:draft-1' } + const onRemoveAttachment = vi.fn() + const { view, textarea, props } = setup({ + draft: '', attachments: [attachment], onRemoveAttachment, + }) + const send = view.getByRole('button', { name: '发送' }) as HTMLButtonElement + expect(send.disabled).toBe(false) + fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(props.onSend).toHaveBeenCalledWith('queue') + + fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' })) + expect(onRemoveAttachment).toHaveBeenCalledWith('draft-1') + fireEvent.doubleClick(view.getByTitle('双击查看原图')) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + expect(view.getAllByAltText('pixel.png').every(node => (node as HTMLImageElement).src.includes('blob:draft-1'))).toBe(true) + fireEvent.keyDown(window, { key: 'Escape' }) + expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() + }) +}) diff --git a/packages/client/ui-conversation/tests/message-image.spec.tsx b/packages/client/ui-conversation/tests/message-image.spec.tsx new file mode 100644 index 0000000000..d78fca2069 --- /dev/null +++ b/packages/client/ui-conversation/tests/message-image.spec.tsx @@ -0,0 +1,44 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { MessageImage } from '../src/client/chat/MessageImage.tsx' + +afterEach(cleanup) + +const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 68, + width: 640, + height: 320, + name: 'history.png', +} + +describe('MessageImage', () => { + it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => { + const load = vi.fn().mockResolvedValue('blob:history') + const view = render() + const frame = view.getByRole('button', { name: 'history.png,双击查看原图' }) + expect(frame.getAttribute('style')).toContain('width: 240px') + expect(frame.getAttribute('style')).toContain('height: 120px') + await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) + expect(load).toHaveBeenCalledWith(attachment) + fireEvent.doubleClick(frame) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: '关闭原图预览' })) + expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() + }) + + it('surfaces a retry control when durable bytes cannot be read', async () => { + const load = vi.fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce('blob:retry') + const view = render() + const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) + fireEvent.click(retry) + await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) + expect(load).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts index a637b4fa18..32fb513929 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ b/packages/client/ui-conversation/tests/selection-survival.spec.ts @@ -175,6 +175,6 @@ describe('selection survives on the store seat', () => { await flush() const reborn = storeFor(b, 'conversation', sid('s1')) expect(reborn).not.toBe(doomed) - expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null }) + expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', imageIds: [], view: null }) }) }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 8b8f4f0dff..b82276e555 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -93,6 +93,29 @@ describe('send / cancel', () => { await expect(s.send('x', 'queue')).rejects.toThrow(/send failed: agent-busy: busy/) }) + it('uploads temporary browser files as base64 image parts at the send boundary', async () => { + const b = await bench() + const file = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' }) + Object.defineProperty(file, 'arrayBuffer', { + value: () => Promise.resolve(Uint8Array.of(1, 2, 3).buffer), + }) + await b.scopedSvc(sid('s1')).send('describe', 'queue', [file]) + expect(b.sessionDoubles.get(sid('s1'))!.prompt).toHaveBeenCalledWith([ + { type: 'image', mediaType: 'image/png', data: 'AQID', name: 'pixel.png' }, + { type: 'text', text: 'describe' }, + ], 'queue') + }) + + it('rejects unsupported browser media before prompting the session', async () => { + const b = await bench() + const file = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' }) + Object.defineProperty(file, 'arrayBuffer', { + value: () => Promise.resolve(Uint8Array.of(1).buffer), + }) + await expect(b.scopedSvc(sid('s1')).send('', 'queue', [file])).rejects.toThrow(/不支持的图片格式/) + expect(b.sessionDoubles.get(sid('s1'))?.prompt).not.toHaveBeenCalled() + }) + it('cancel resolves on ok and throws the folded business error', async () => { const b = await bench() const s = b.scopedSvc(sid('s1')) diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index 52d89d2b19..1d4b76091a 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -71,6 +71,9 @@ describe('ConversationRoot branches', () => { useStore={hookOf(chat)} actions={chat.actions} views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }} + addImages={vi.fn()} + removeImage={vi.fn()} + draftImages={() => []} send={vi.fn()} stop={vi.fn()} openDetails={vi.fn()} @@ -129,6 +132,9 @@ describe('ConversationRoot branches', () => { useStore={hookOf(chat)} actions={chat.actions} views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }} + addImages={vi.fn()} + removeImage={vi.fn()} + draftImages={() => []} send={vi.fn()} stop={vi.fn()} openDetails={vi.fn()} diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 75172dd207..d7e490c7ee 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -121,6 +121,9 @@ describe('ConversationRoot', () => { subscribe: () => () => {}, version: () => 1, }} + addImages={vi.fn()} + removeImage={vi.fn()} + draftImages={() => []} send={send} stop={stop} openDetails={openDetails} @@ -183,7 +186,7 @@ describe('ConversationRoot', () => { // Typing goes through actions.setDraft into the shared store. expect(chat.store.getSnapshot().draft).toBe('hi') fireEvent.keyDown(box, { key: 'Enter' }) - expect(send).toHaveBeenCalledWith('hi', 'queue') + expect(send).toHaveBeenCalledWith('hi', [], 'queue') }) }) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 7755337093..d8730e7a1a 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../attachment/attachment" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index e7f99d9128..6b35741846 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -96,6 +96,9 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] = subscribe: (fn) => svc.subscribeViews(fn), version: () => svc.viewsVersion(), }} + addImages={vi.fn()} + removeImage={vi.fn()} + draftImages={() => []} send={vi.fn()} stop={vi.fn()} openDetails={vi.fn()} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 3b9111f558..52ff1dd7ff 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -152,6 +152,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'attachments', + summary: 'Immutable binary attachment service.', + methods: [ + { + signature: 'abstract saveImage(input: SaveImageAttachment): Promise', + jsDoc: '/**\n * Validate and durably commit one image before its owning session event is appended.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns a durable content-addressed reference.\n */', + }, + { + signature: 'abstract readImage(ref: ImageAttachmentRef): Promise', + jsDoc: '/**\n * Read one image and verify that bytes still match the recorded reference.\n * @param ref - durable reference from the session log.\n * @returns the verified bytes and canonical reference.\n */', + }, + ], + }, { key: 'bash', summary: 'Abstract bash execution service.', @@ -1195,6 +1209,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AssistantProvenance', declaration: 'export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n}', }, + { + name: 'AttachmentId', + declaration: 'export type AttachmentId = Branded<\'AttachmentId\'>;', + }, { name: 'BashEnvContributor', declaration: 'export interface BashEnvContributor {\n name: string;\n variables: Readonly>;\n resolve(execution: ToolExecution): Readonly>>;\n}', @@ -1309,7 +1327,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ContentBlockMap', - declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'tool-call\': ToolCallBlock;\n \'tool-result\': ToolResultBlock;\n}', + declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'image\': ImageBlock;\n \'tool-call\': ToolCallBlock;\n \'tool-result\': ToolResultBlock;\n}', }, { name: 'ContentBlockType', @@ -1451,6 +1469,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'HookContext', declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n meta?: JsonValue;\n}', }, + { + name: 'ImageAttachmentRef', + declaration: 'export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n}', + }, + { + name: 'ImageBlock', + declaration: 'export interface ImageBlock {\n type: \'image\';\n attachment: ImageAttachmentRef;\n alt?: string;\n}', + }, + { + name: 'ImageMediaType', + declaration: 'export type ImageMediaType = \'image/png\' | \'image/jpeg\' | \'image/webp\' | \'image/gif\';', + }, { name: 'InjectOptions', declaration: 'export interface InjectOptions extends Omit {\n meta?: JsonValue;\n}', @@ -1481,7 +1511,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmModelInfo', - declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}', + declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n inputModalities?: readonly ModelModality[];\n outputModalities?: readonly ModelModality[];\n}', }, { name: 'LlmProviderInfo', @@ -1499,6 +1529,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'MessageSourceMap', declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}', }, + { + name: 'ModelModality', + declaration: 'export type ModelModality = ModelModalityMap[keyof ModelModalityMap];', + }, + { + name: 'ModelModalityMap', + declaration: 'export interface ModelModalityMap {\n text: \'text\';\n image: \'image\';\n}', + }, { name: 'OutOfBandSessionEventMap', declaration: 'export interface OutOfBandSessionEventMap {\n}', @@ -1651,6 +1689,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SandboxPolicyRequest', declaration: 'export interface SandboxPolicyRequest {\n session?: Session;\n mode?: SandboxMode;\n}', }, + { + name: 'SaveImageAttachment', + declaration: 'export interface SaveImageAttachment {\n data: Uint8Array;\n mediaType: ImageMediaType;\n name?: string;\n}', + }, { name: 'SaveTextSpill', declaration: 'export interface SaveTextSpill {\n owner: SpillOwner;\n source: SpillSource;\n suggestedName: string;\n content: string;\n}', @@ -1827,6 +1869,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SpillSource', declaration: 'export interface SpillSource {\n toolName: string;\n callId: CallId;\n label: string;\n}', }, + { + name: 'StoredImageAttachment', + declaration: 'export interface StoredImageAttachment {\n ref: ImageAttachmentRef;\n data: Uint8Array;\n}', + }, { name: 'StreamChunk', declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: unknown;\n};', diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index fe50c16a60..5b80030dc9 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -40,6 +40,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index cb8de392f5..4b484ae594 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -5,6 +5,9 @@ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' +import { imageMediaTypeSchema } from './sessions.schema.ts' + +const modalitySchema = z.union([z.literal('text'), z.literal('image')]) /** host.describe request payload (empty object literal). */ export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType>> @@ -15,5 +18,20 @@ export const hostDescribeValueSchema = z.object({ cwd: z.string(), provider: z.string().optional(), model: z.string().optional(), + activeModel: z.object({ + provider: z.string(), + id: z.string(), + name: z.string(), + description: z.string().optional(), + inputModalities: z.array(modalitySchema).optional(), + outputModalities: z.array(modalitySchema).optional(), + }).optional(), + imageLimits: z.object({ + maxImageBytes: z.number().int().positive(), + maxImagesPerMessage: z.number().int().positive(), + maxMessageImageBytes: z.number().int().positive(), + maxImagePixels: z.number().int().positive(), + mediaTypes: z.array(imageMediaTypeSchema), + }).optional(), attachedSessions: z.number().int().nonnegative(), }) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index fd07b33ced..ccf23d5cfb 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -4,6 +4,8 @@ */ import type { RpcRequest, RpcResponse } from './rpc.ts' +import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' +import type { LlmModelInfo } from '@deepseek-ai/dsh-llm/types' /** Host-level unary methods. */ export interface HostApi { @@ -20,6 +22,10 @@ export interface HostApi { cwd: string provider?: string model?: string + /** Catalog entry for the active route; absent means its capabilities are unknown. */ + activeModel?: LlmModelInfo + /** Resolved authoritative image-upload limits. */ + imageLimits?: ImageAttachmentLimits attachedSessions: number }>> } diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index c51c785a7c..dc0ac0204d 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -19,7 +19,7 @@ export interface ApiProxy { } // ---- Domain interfaces and payload entities ---- -export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts' +export type { HistoryEntry, PromptContentPart, SessionsApi, SessionSummary } from './sessions.ts' export type { HostApi } from './host.ts' export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { ApprovalResponsePayload } from './approvals.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index b37cc062ff..74b05fdb3c 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -14,6 +14,7 @@ export interface RpcMethodMap { 'session.create': SessionsApi['create'] 'session.history': SessionsApi['history'] 'session.prompt': SessionsApi['prompt'] + 'session.attachment': SessionsApi['attachment'] 'session.cancel': SessionsApi['cancel'] 'host.describe': HostApi['describe'] } diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index d993e763b7..ba998cd86a 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -35,6 +35,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom()) }) }), z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), + z.object({ code: z.literal('attachment-error'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 46f737817c..4273a32e71 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -32,6 +32,7 @@ export interface RpcErrorDetailsMap { 'bad-request': { issues: ZodIssue[] } 'session-not-found': { sessionId: SessionId } 'agent-busy': { reason: string } + 'attachment-error': { reason: string } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 3edf0e6014..1eacc61715 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -11,6 +11,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { HistoryEntry, SessionSummary } from './sessions.ts' import type { ToolEventView } from './events.ts' +import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' /** SessionId: one brand cast after shape validation (the only cast point in this domain). */ export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType @@ -84,14 +85,25 @@ export const sessionHistoryValueSchema = z.object({ hasMore: z.boolean(), }) satisfies z.ZodType>> -/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */ -export const contentBlockSchema = z.looseObject({ type: z.string() }) +/** Raster image media types accepted by the version-one browser wire. */ +export const imageMediaTypeSchema = z.union([ + z.literal('image/png'), + z.literal('image/jpeg'), + z.literal('image/webp'), + z.literal('image/gif'), +]) + +/** Prompt wire content is intentionally narrower than merge-extensible durable core content. */ +export const promptContentPartSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), text: z.string() }), + z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional() }), +]) /** session.prompt request payload. */ export const sessionPromptRequestSchema = z.object({ sessionId: sessionIdSchema, mode: z.union([z.literal('queue'), z.literal('steer')]), - content: z.array(contentBlockSchema), + content: z.array(promptContentPartSchema), }) as unknown as z.ZodType> /** session.prompt response value. */ @@ -99,6 +111,31 @@ export const sessionPromptValueSchema = z.object({ accepted: z.literal(true), }) satisfies z.ZodType>> +/** Opaque attachment id after string-shape validation. */ +export const attachmentIdSchema = z.string().min(1) as unknown as z.ZodType + +/** Durable image reference returned from the authenticated session lookup. */ +export const imageAttachmentRefSchema = z.object({ + attachmentId: attachmentIdSchema, + mediaType: imageMediaTypeSchema, + bytes: z.number().int().positive(), + width: z.number().int().positive(), + height: z.number().int().positive(), + name: z.string().optional(), +}) as unknown as z.ZodType + +/** session.attachment request payload. */ +export const sessionAttachmentRequestSchema = z.object({ + sessionId: sessionIdSchema, + attachmentId: attachmentIdSchema, +}) satisfies z.ZodType>> + +/** session.attachment response value. */ +export const sessionAttachmentValueSchema = z.object({ + attachment: imageAttachmentRefSchema, + data: z.string(), +}) satisfies z.ZodType>> + /** session.cancel request payload. */ export const sessionCancelRequestSchema = z.object({ sessionId: sessionIdSchema, diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 393303f817..6b3dedfd35 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -4,7 +4,7 @@ * else references RequestPayload<'session.*'> / ResponseValue<'session.*'>. */ -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { AttachmentIdType, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts' import type { ToolEventView } from './events.ts' @@ -44,6 +44,11 @@ export interface SessionSummary { cwd?: string } +/** Browser-submitted prompt content; image bytes are promoted to durable references by the host. */ +export type PromptContentPart = + | { type: 'text'; text: string } + | { type: 'image'; mediaType: ImageMediaType; data: string; name?: string } + /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ export interface SessionsApi { /** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */ @@ -64,10 +69,14 @@ export interface SessionsApi { history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>): Promise> - /** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */ - prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>): + /** Sends text plus temporary base64 image uploads; the host persists images before calling the agent. */ + prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: PromptContentPart[] }>): Promise> + /** Reads one durable image after proving that this session's log references its id. */ + attachment(request: RpcRequest<{ sessionId: SessionId; attachmentId: AttachmentIdType }> ): + Promise> + /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 901cf7bd2a..406e66baea 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -16,6 +16,7 @@ import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts' import { hostDescribeValueSchema } from '../api/host.schema.ts' import { sessionCancelValueSchema, + sessionAttachmentValueSchema, sessionCreateValueSchema, sessionHistoryValueSchema, sessionListValueSchema, @@ -43,6 +44,7 @@ export interface IApiClient { create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise>> history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> + attachment(payload: RequestPayload<'session.attachment'>, signal?: AbortSignal): Promise>> cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> } host: { @@ -65,6 +67,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('session.create', payload, signal), history: (payload, signal) => this.callUnary('session.history', payload, signal), prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal), + attachment: (payload, signal) => this.callUnary('session.attachment', payload, signal), cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 03b9f6500f..c28305a1c5 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -16,6 +16,7 @@ import type { Wire } from '../api/rpc.schema.ts' import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts' import { sessionCancelRequestSchema, + sessionAttachmentRequestSchema, sessionCreateRequestSchema, sessionHistoryRequestSchema, sessionListRequestSchema, @@ -42,6 +43,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) }, 'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) }, 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, + 'session.attachment': { schema: sessionAttachmentRequestSchema, invoke: (api, r) => api.sessions.attachment(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, } diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 25af7e2f75..818a1d5b09 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -30,6 +30,10 @@ function scriptedApi(overrides: { create: r => ok(r, { sessionId: sid('s-new') }), history: r => ok(r, { events: [], hasMore: false }), prompt: r => ok(r, { accepted: true as const }), + attachment: r => ok(r, { + attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + data: 'AA==', + }), cancel: r => ok(r, { accepted: true as const }), ...overrides.sessions, }, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index d097daecef..336b61efb8 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -33,6 +33,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async prompt(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, + async attachment(request) { + return { + rpcId: request.rpcId, + result: { ok: true, value: { attachment: { attachmentId: 'a' as never, mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }, data: 'AA==' } }, + } + }, async cancel(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 764c9673b9..16b986a941 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -6,7 +6,8 @@ import { } from '../src/api/rpc.schema.ts' import { z } from 'zod' import { - contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema, + promptContentPartSchema, sessionAttachmentRequestSchema, sessionAttachmentValueSchema, + sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema, sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema, sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema, sessionSummarySchema, @@ -31,6 +32,7 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request') expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') + expect(rpcErrorSchema.parse({ code: 'attachment-error', message: 'm', details: { reason: 'r' } }).code).toBe('attachment-error') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -105,7 +107,20 @@ describe('sessions domain schemas', () => { expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true) expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true) - expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 }) + expect(promptContentPartSchema.parse({ type: 'text', text: 'x', extra: 1 })).toEqual({ type: 'text', text: 'x' }) + expect(promptContentPartSchema.parse({ + type: 'image', mediaType: 'image/png', data: 'AA==', name: 'pixel.png', + })).toMatchObject({ type: 'image', mediaType: 'image/png', name: 'pixel.png' }) + const attachment = { + attachmentId: `sha256:${'a'.repeat(64)}`, + mediaType: 'image/png' as const, + bytes: 1, + width: 1, + height: 1, + } + expect(sessionAttachmentRequestSchema.parse({ sessionId: 's1', attachmentId: attachment.attachmentId })) + .toMatchObject({ sessionId: 's1' }) + expect(sessionAttachmentValueSchema.parse({ attachment, data: 'AA==' }).attachment).toEqual(attachment) }) }) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 718c5a9042..d7fbdec7a5 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../util/brand" }, + { + "path": "../../attachment/attachment" + }, { "path": "../../llm/llm" }, diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index ea32040012..c3d90d776e 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -31,6 +31,7 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-attachment-local": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-i18n": "workspace:^", @@ -46,6 +47,7 @@ "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 0621fbecb9..8681cb3b3e 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -9,10 +9,12 @@ import { randomUUID } from 'node:crypto' import { stat } from 'node:fs/promises' import type { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import { AttachmentError } from '@deepseek-ai/dsh-attachment-local' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment-local' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, PromptContentPart, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -22,6 +24,69 @@ const DEFAULT_MAX_MESSAGES = 50 /** Surface message event types (the pagination counting unit). */ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message']) +function decodeBase64(data: string): Uint8Array { + if (data.length === 0 || data.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)) { + throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') + } + const decoded = Buffer.from(data, 'base64') + if (decoded.toString('base64') !== data) throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') + return new Uint8Array(decoded) +} + +async function durablePromptContent(ctx: Context, content: readonly PromptContentPart[]): Promise { + const limits = ctx.attachments.imageLimits + const prepared = content.map(part => part.type === 'text' + ? part + : { part, data: decodeBase64(part.data) }) + const images = prepared.filter((part): part is Extract => 'data' in part) + if (images.length > limits.maxImagesPerMessage) { + throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') + } + const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0) + if (totalBytes > limits.maxMessageImageBytes) { + throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') + } + return Promise.all(prepared.map(async (item): Promise => { + if (!('data' in item)) return { type: 'text', text: item.text } + const attachment = await ctx.attachments.saveImage({ + data: item.data, + mediaType: item.part.mediaType, + ...item.part.name === undefined ? {} : { name: item.part.name }, + }) + return { type: 'image', attachment } + })) +} + +function imageInContent(content: unknown, attachmentId: string): ImageAttachmentRef | undefined { + if (!Array.isArray(content)) return undefined + for (const value of content) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + const block = value as { type?: unknown; attachment?: unknown; content?: unknown } + if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { + const ref = block.attachment as ImageAttachmentRef + if (String(ref.attachmentId) === attachmentId) return ref + } + if (block.type === 'tool-result') { + const nested = imageInContent(block.content, attachmentId) + if (nested !== undefined) return nested + } + } + return undefined +} + +function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined { + for (const event of events) { + const data = event.data as { content?: unknown; chunk?: { type?: unknown; block?: unknown } } + const direct = imageInContent(data.content, attachmentId) + if (direct !== undefined) return direct + if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') { + const streamed = imageInContent([data.chunk.block], attachmentId) + if (streamed !== undefined) return streamed + } + } + return undefined +} + /** * Message-boundary pagination: count maxMessages surface messages backwards from * the window tail; the cut is the starting seq of the oldest message group @@ -321,15 +386,52 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { - if (mode === 'steer') agent.steer(content, { source }) - else agent.send(content, { source }) + if (content.some(part => part.type === 'image')) { + const activeModel = (await ctx.llm.listModels(defaults.provider)).find(model => model.id === defaults.model) + if (activeModel?.inputModalities !== undefined && !activeModel.inputModalities.includes('image')) { + return err(request, { + code: 'attachment-error', + message: `Model "${defaults.model}" does not support image input.`, + details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, + }) + } + } + const durable = await durablePromptContent(ctx, content) + if (mode === 'steer') agent.steer(durable, { source }) + else agent.send(durable, { source }) } catch (error: unknown) { + if (error instanceof AttachmentError) { + return err(request, { code: 'attachment-error', message: error.message, details: { reason: error.code } }) + } // A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached. return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } }) } return ok(request, { accepted: true as const }) }, + async attachment(request) { + const { sessionId, attachmentId } = request.payload + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const ref = referencedImage(found.agent.session.events, String(attachmentId)) + if (ref === undefined) { + return err(request, { + code: 'attachment-error', + message: 'Image is not referenced by this session.', + details: { reason: 'ATTACHMENT_NOT_REFERENCED' }, + }) + } + try { + const stored = await ctx.attachments.readImage(ref) + return ok(request, { attachment: stored.ref, data: Buffer.from(stored.data).toString('base64') }) + } catch (error: unknown) { + if (error instanceof AttachmentError) { + return err(request, { code: 'attachment-error', message: error.message, details: { reason: error.code } }) + } + return err(request, { code: 'internal', message: 'Unable to read image attachment.', details: {} }) + } + }, + cancel(request) { const { sessionId } = request.payload const agent = ctx.agents.get(sessionId) @@ -346,15 +448,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, host: { - describe(request) { + async describe(request) { + const activeModel = (await ctx.llm.listModels(defaults.provider)).find(model => model.id === defaults.model) // TODO(step2): version should read apps/cli's package.json; placeholder for now. - return Promise.resolve(ok(request, { + return ok(request, { version: '0.0.1', cwd: process.cwd(), provider: defaults.provider, model: defaults.model, + ...activeModel === undefined ? {} : { activeModel }, + imageLimits: { + ...ctx.attachments.imageLimits, + mediaTypes: [...ctx.attachments.imageLimits.mediaTypes], + }, attachedSessions: ctx.agents.list().length, - })) + }) }, }, diff --git a/packages/host/runtime/src/boot.ts b/packages/host/runtime/src/boot.ts index ca40b9f8b3..651f2d969e 100644 --- a/packages/host/runtime/src/boot.ts +++ b/packages/host/runtime/src/boot.ts @@ -7,6 +7,7 @@ import { Context } from 'cordis' import Timer from '@cordisjs/plugin-timer' import LlmService from '@deepseek-ai/dsh-llm' +import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -14,6 +15,8 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import TaskService from '@deepseek-ai/dsh-tasks' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import type { PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import * as toolBash from '@deepseek-ai/dsh-tool-bash' @@ -42,10 +45,14 @@ import * as spillPolicy from '@deepseek-ai/dsh-spill-policy' export interface BootHostOptions { /** Root directory for JSONL session persistence. */ persistenceRoot: string + /** Explicit harness home for durable attachments; omitted follows DSH_HOME then ~/.dsh. */ + dshHome?: string /** Default provider route for created/resumed agents (defaults to 'deepseek', the only adapter bootHost registers). */ provider?: string /** Default model id (defaults to 'deepseek-v4-flash', matching the demos). */ model?: string + /** Additional pi-ai provider routes available to visual-capable Web sessions. */ + piAiProviders?: PiAiProviderProfile[] /** * Default project directory for sessions created without an explicit cwd * (defaults to the host process working directory). A session's cwd is its @@ -88,6 +95,9 @@ export async function bootHost(options: BootHostOptions): Promise { const ctx = new Context() await ctx.plugin(Timer) await ctx.plugin(LlmService) + await ctx.plugin(LocalAttachmentStore, { + ...options.dshHome === undefined ? {} : { dshHome: options.dshHome }, + }) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRegistry) @@ -95,6 +105,9 @@ export async function bootHost(options: BootHostOptions): Promise { await ctx.plugin(TaskService) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, {}) + if (options.piAiProviders !== undefined && options.piAiProviders.length > 0) { + await ctx.plugin(LlmPiAi, { providers: options.piAiProviders }) + } await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot, compression: 'none' }) await ctx.plugin(LocalBashExecutor, {}) // Tool suite mirroring the demo:repl composition (repl-agent/cordis.yml + diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index f3a2dc9a82..c48931e579 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -1,11 +1,11 @@ -import { mkdtempSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, LlmModelInfo, ModelModality, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import type { HostFrame, MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -15,10 +15,20 @@ import { bootHost, startHost, type HostHandle, type RunningHost } from '../src/i /** Scripted adapter: each model call consumes the next chunk list; 'hang' streams then waits for abort. */ class ScriptedAdapter extends LlmAdapter { - constructor(private script: (StreamChunk[] | 'hang')[]) { + constructor( + private script: (StreamChunk[] | 'hang')[], + private readonly inputModalities: readonly ModelModality[] = ['text', 'image'], + ) { super() } + override listModels(provider: string): Promise { + return Promise.resolve([{ + provider, id: 'test-model', name: 'test-model', + inputModalities: this.inputModalities, outputModalities: ['text'], + }]) + } + async * stream(options: GenerateOptions): AsyncIterable { const entry = this.script.shift() if (!entry) throw new Error('ScriptedAdapter: script exhausted') @@ -48,6 +58,8 @@ function request

(payload: P): RpcRequest

{ } let nextRpc = 1 +const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=' + function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject: Agent, status: string) => { @@ -171,14 +183,83 @@ describe('sessions.prompt / cancel', () => { }) it('maps a synchronous send throw to agent-busy', async () => { - const { api } = await boot() + const { api, ctx } = await boot() const { sessionId } = expectOk(await api.sessions.create(request({}))) - const poisoned = [{ type: 'text', text: 'x', bad: () => 1 }] as never - const response = await api.sessions.prompt(request({ sessionId, mode: 'queue' as const, content: poisoned })) + vi.spyOn(ctx.agents.get(sessionId) as Agent, 'send').mockImplementation(() => { + throw new Error('disposed during prompt') + }) + const response = await api.sessions.prompt(request({ + sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }], + })) expect(response.result.ok).toBe(false) if (!response.result.ok) expect(response.result.error.code).toBe('agent-busy') }) + it('persists uploaded bytes before the user event and serves them only through the owning session', async () => { + const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-image-session-')) + const dshHome = mkdtempSync(join(tmpdir(), 'dsh-image-home-')) + host = await startHost({ + boot: { persistenceRoot, dshHome, provider: 'scripted', model: 'test-model' }, + }) + host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('seen')])) + const { sessionId } = expectOk(await host.api.sessions.create(request({}))) + const agent = host.ctx.agents.get(sessionId) as Agent + const idle = waitForIdle(host.ctx, agent) + const response = await host.api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [ + { type: 'text' as const, text: 'describe' }, + { type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64, name: '/tmp/pixel.png' }, + ], + })) + expectOk(response) + await idle + + const user = agent.session.events.find(event => event.type === 'user/message') + const content = (user?.data as { content?: ContentBlock[] } | undefined)?.content ?? [] + const image = content.find(block => block.type === 'image') + expect(image?.type).toBe('image') + if (image?.type !== 'image') throw new Error('image block missing') + expect(JSON.stringify(user)).not.toContain(PNG_BASE64) + expect(image.attachment.name).toBe('pixel.png') + const sha256 = String(image.attachment.attachmentId).slice('sha256:'.length) + const object = join(dshHome, 'attachments', 'v1', 'objects', sha256.slice(0, 2), sha256) + expect(existsSync(object)).toBe(true) + expect(readFileSync(object).toString('base64')).toBe(PNG_BASE64) + + const loaded = expectOk(await host.api.sessions.attachment(request({ + sessionId, attachmentId: image.attachment.attachmentId, + }))) + expect(loaded).toEqual({ attachment: image.attachment, data: PNG_BASE64 }) + const { sessionId: other } = expectOk(await host.api.sessions.create(request({}))) + const denied = await host.api.sessions.attachment(request({ + sessionId: other, attachmentId: image.attachment.attachmentId, + })) + expect(denied.result).toMatchObject({ + ok: false, error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }, + }) + }) + + it('rejects images for an explicitly text-only model without creating a session event', async () => { + const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-text-session-')) + const dshHome = mkdtempSync(join(tmpdir(), 'dsh-text-home-')) + host = await startHost({ + boot: { persistenceRoot, dshHome, provider: 'scripted', model: 'test-model' }, + }) + host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text'])) + const { sessionId } = expectOk(await host.api.sessions.create(request({}))) + const response = await host.api.sessions.prompt(request({ + sessionId, mode: 'queue' as const, + content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64 }], + })) + expect(response.result).toMatchObject({ + ok: false, error: { code: 'attachment-error', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } }, + }) + expect(host.ctx.agents.get(sessionId)?.session.events.some(event => event.type === 'user/message')).toBe(false) + expect(existsSync(join(dshHome, 'attachments'))).toBe(false) + }) + it('cancels an attached agent and rejects an unattached one', async () => { const running = await boot(['hang']) const { api, ctx } = running diff --git a/packages/host/runtime/tsconfig.json b/packages/host/runtime/tsconfig.json index 9836e0b5fd..1264a1954c 100644 --- a/packages/host/runtime/tsconfig.json +++ b/packages/host/runtime/tsconfig.json @@ -17,9 +17,15 @@ { "path": "../../llm/llm" }, + { + "path": "../../attachment/attachment-local" + }, { "path": "../../llm/llm-deepseek" }, + { + "path": "../../llm/llm-pi-ai" + }, { "path": "../../core/session" }, diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 3ca81f678c..331fc1c34b 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -116,6 +116,8 @@ export class DeepSeekAdapter extends LlmAdapter { id: model.id, name: model.name ?? model.id, ...model.description === undefined ? {} : { description: model.description }, + inputModalities: ['text'], + outputModalities: ['text'], }))) } diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index f463a4e30e..5c411cfe15 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -2,10 +2,12 @@ * Serialize harness messages into DeepSeek chat completions. User text is joined; assistant text * becomes `content`, tool calls become `tool_calls`, and tool results become separate tool messages. * Assistant reasoning is replayed as `reasoning_content` only on tool-call turns, as required by - * thinking-mode passback. Unknown declaration-merged block types are skipped rather than rejected. + * thinking-mode passback. Core image blocks are rejected explicitly because this wire route is text-only; + * unknown declaration-merged block types retain the adapter's documented extension fallback. * @module dsh-llm-deepseek/serialize */ +import { LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { WireMessage, WireRequest, WireTool } from './types.ts' @@ -23,6 +25,16 @@ function flattenText(blocks: ContentBlock[]): string { .join('') } +/** Reject core image content before any text-flattening path can silently erase it. */ +function assertTextOnly(blocks: readonly ContentBlock[]): void { + for (const block of blocks) { + if (block.type === 'image') { + throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT') + } + if (block.type === 'tool-result') assertTextOnly(block.content) + } +} + /** Serialize one assistant message (text + reasoning + tool calls). */ function serializeAssistant(message: Message): WireMessage { const text = flattenText(message.content) @@ -68,6 +80,7 @@ function serializeAssistant(message: Message): WireMessage { export function serializeMessages(messages: Message[]): WireMessage[] { const wire: WireMessage[] = [] for (const message of messages) { + assertTextOnly(message.content) if (message.role === 'system') { wire.push({ role: 'system', content: flattenText(message.content) }) continue diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index f147323645..9f007040e7 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -528,8 +528,8 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' }, - { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' }, + { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash', inputModalities: ['text'], outputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro', inputModalities: ['text'], outputModalities: ['text'] }, ]) await expect(ctx.llm.resolveModelContext('deepseek', 'deepseek-v4-flash')) .resolves.toEqual({ contextWindow: 128_000 }) @@ -540,8 +540,8 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmService) LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' }, - { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' }, + { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash', inputModalities: ['text'], outputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro', inputModalities: ['text'], outputModalities: ['text'] }, ]) }) @@ -562,8 +562,8 @@ describe('plugin registration and config', () => { ], }) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'private-fast', name: 'private-fast' }, - { provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' }, + { provider: 'deepseek', id: 'private-fast', name: 'private-fast', inputModalities: ['text'], outputModalities: ['text'] }, + { provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'], outputModalities: ['text'] }, ]) await expect(ctx.llm.resolveModelContext('deepseek', 'private-fast')) .resolves.toEqual({ contextWindow: 32_000 }) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index e84909fc20..901697378a 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { CallId } from '@deepseek-ai/dsh-llm' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { serializeMessages, serializeRequest } from '../src/serialize.ts' @@ -123,6 +124,19 @@ describe('serializeMessages', () => { expect(wire).toEqual([{ role: 'user', content: 'see chart' }]) }) + it('rejects image blocks instead of silently flattening them away', () => { + expect(() => serializeMessages([{ + role: 'user', + content: [{ + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', bytes: 68, width: 1, height: 1, + }, + }], + }])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + }) + it('emits an empty user message rather than dropping block-less messages', () => { const wire = serializeMessages([{ role: 'user', content: [] }]) expect(wire).toEqual([{ role: 'user', content: '' }]) diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 590e49f323..ac09dbf2c6 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -27,6 +27,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-attachment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", @@ -37,6 +38,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index ed0fb9fae4..64cdb1699f 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -12,6 +12,7 @@ import type { Model, SimpleStreamOptions, } from '@earendil-works/pi-ai' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' @@ -24,6 +25,8 @@ import { toStreamChunks } from './stream.ts' export interface PiAiAdapterOptions { /** Validated provider profiles this adapter instance owns. */ profiles: readonly PiAiProviderProfile[] + /** Durable image resolver used only when a request contains image references. */ + attachments?: AttachmentStore } /** @@ -69,10 +72,12 @@ function requestHeaders(headers: Readonly> | undefined): */ export class PiAiAdapter extends LlmAdapter { private readonly profiles: ReadonlyMap + private readonly attachments: AttachmentStore | undefined constructor(options: PiAiAdapterOptions) { super() this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile])) + this.attachments = options.attachments } override listModels(provider: string): Promise { @@ -84,6 +89,8 @@ export class PiAiAdapter extends LlmAdapter { provider, id: model.id, name: model.name, + inputModalities: [...model.input], + outputModalities: ['text'], }))) } @@ -112,7 +119,6 @@ export class PiAiAdapter extends LlmAdapter { throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER') } const model = resolveModel(profile, options.model) - const consumer = new AbortController() const upstream = options.signal === undefined ? consumer.signal @@ -121,7 +127,22 @@ export class PiAiAdapter extends LlmAdapter { using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT') try { - const events = streamSimple(model, toPiContext(options), { + const containsImage = options.messages.some((message) => { + // The discriminant is part of same-process message validity and is read before content. + void message.role + return message.content.some(block => block.type === 'image' + || (block.type === 'tool-result' && block.content.some(piece => piece.type === 'image'))) + }) + if (containsImage && !model.input.includes('image')) { + throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT') + } + if (containsImage && this.attachments === undefined) { + throw new LlmError('pi-ai image input requires the durable attachment service', 'UNSUPPORTED_CONTENT') + } + const context = this.attachments === undefined + ? toPiContext(options) + : await toPiContext(options, this.attachments) + const events = streamSimple(model, context, { ...profileOptions(profile), ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index ddb8284448..90d2176b1c 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -4,9 +4,10 @@ * @module dsh-llm-pi-ai/context */ -import { CallId } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import type { Context as PiContext, Message as PiMessage, Tool as PiTool } from '@earendil-works/pi-ai' +import { CallId, LlmError } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai' import { toPiAssistant } from './replay.ts' /** Join the text blocks of a harness message. */ @@ -17,20 +18,122 @@ function flattenText(message: Message): string { .join('') } +async function userContent( + blocks: readonly ContentBlock[], + attachments: AttachmentStore, +): Promise { + const content: (TextContent | ImageContent)[] = [] + for (const block of blocks) { + switch (block.type) { + case 'text': + if (block.text.length > 0) content.push({ type: 'text', text: block.text }) + break + case 'image': { + const stored = await attachments.readImage(block.attachment) + content.push({ + type: 'image', + data: Buffer.from(stored.data).toString('base64'), + mimeType: stored.ref.mediaType, + }) + break + } + case 'tool-result': + break + default: + // Other merge-extensible blocks are not user-input vocabulary for pi-ai. + break + } + } + if (content.every(block => block.type === 'text')) return content.map(block => block.text).join('') + return content +} + +function toolsOf(options: GenerateOptions): PiTool[] | undefined { + return options.tools?.map(tool => ({ + name: tool.name, + description: tool.description, + // ToolSchema.parameters is a JSON Schema object; pi-ai's TSchema + // (TypeBox) is structurally JSON Schema, so it assigns directly. + parameters: tool.parameters, + })) +} + +/** Assemble the request-level pi-ai context envelope shared by both conversion paths. */ +function piContext(options: GenerateOptions, messages: PiMessage[]): PiContext { + const tools = toolsOf(options) + return { + ...options.system !== undefined ? { systemPrompt: options.system } : {}, + messages, + ...tools !== undefined && tools.length > 0 ? { tools } : {}, + } +} + +function textOnlyContext(options: GenerateOptions): PiContext { + const toolNames = new Map() + const messages: PiMessage[] = [] + for (const message of options.messages) { + if (message.content.some(block => block.type === 'image' + || (block.type === 'tool-result' && block.content.some(piece => piece.type === 'image')))) { + throw new LlmError('pi-ai image conversion requires the durable attachment service', 'UNSUPPORTED_CONTENT') + } + if (message.role === 'system') { + messages.push({ role: 'user', content: flattenText(message), timestamp: 0 }) + continue + } + if (message.role === 'assistant') { + const assistant = toPiAssistant(message) + for (const block of assistant.content) if (block.type === 'toolCall') toolNames.set(CallId(block.id), block.name) + messages.push(assistant) + continue + } + const text = flattenText(message) + const results = message.content.filter(block => block.type === 'tool-result') + if (text.length > 0 || results.length === 0) messages.push({ role: 'user', content: text, timestamp: 0 }) + for (const result of results) { + messages.push({ + role: 'toolResult', + toolCallId: result.toolCallId, + toolName: toolNames.get(result.toolCallId) ?? 'unknown', + content: [{ + type: 'text', + text: result.content.filter(block => block.type === 'text').map(block => block.text).join('') || '(no output)', + }], + isError: result.isError ?? false, + timestamp: 0, + }) + } + } + return piContext(options, messages) +} + /** - * Convert harness history to a pi-ai Context. Tool results need the tool - * NAME (pi-ai's `toolName`), which the harness doesn't carry on the result - * block — it is recovered from the preceding assistant tool-call with the - * same id. + * Convert text-only harness history to a synchronous pi-ai Context. Tool + * result names are recovered from preceding assistant tool calls. * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. - * @returns the pi-ai context; `tools` is omitted entirely when the request declares none. + * @returns the pi-ai context; `tools` is omitted when the request declares none. */ -export function toPiContext(options: GenerateOptions): PiContext { +export function toPiContext(options: GenerateOptions): PiContext +/** + * Convert harness history to a pi-ai Context while resolving durable images. + * Tool result names are recovered from preceding assistant tool calls. + * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. + * @param attachments - durable byte resolver for image references. + * @returns the asynchronously resolved pi-ai context. + */ +export function toPiContext(options: GenerateOptions, attachments: AttachmentStore): Promise +export function toPiContext(options: GenerateOptions, attachments?: AttachmentStore): PiContext | Promise { + return attachments === undefined ? textOnlyContext(options) : toPiContextWithImages(options, attachments) +} + +async function toPiContextWithImages(options: GenerateOptions, attachments: AttachmentStore): Promise { const toolNames = new Map() const messages: PiMessage[] = [] for (const message of options.messages) { if (message.role === 'system') { + if (message.content.some(block => block.type === 'image')) { + throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT') + } // pi-ai has a single systemPrompt slot; in-history system messages are // folded into user messages to preserve order (rare in practice — the // harness sends the system prompt via options.system). @@ -46,40 +149,26 @@ export function toPiContext(options: GenerateOptions): PiContext { continue } // user role: text + tool results (each result becomes its own message). - const text = flattenText(message) + const regular = message.content.filter(block => block.type !== 'tool-result') + const content = await userContent(regular, attachments) const results = message.content.filter(block => block.type === 'tool-result') - if (text.length > 0 || results.length === 0) { - messages.push({ role: 'user', content: text, timestamp: 0 }) + if (content.length > 0 || results.length === 0) { + messages.push({ role: 'user', content, timestamp: 0 }) } for (const result of results) { + const resultContent = await userContent(result.content, attachments) messages.push({ role: 'toolResult', toolCallId: result.toolCallId, toolName: toolNames.get(result.toolCallId) ?? 'unknown', - content: [{ - type: 'text', - text: result.content - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') || '(no output)', - }], + content: typeof resultContent === 'string' + ? [{ type: 'text', text: resultContent || '(no output)' }] + : resultContent, isError: result.isError ?? false, timestamp: 0, }) } } - const tools: PiTool[] | undefined = options.tools?.map(tool => ({ - name: tool.name, - description: tool.description, - // ToolSchema.parameters is a JSON Schema object; pi-ai's TSchema - // (TypeBox) is structurally JSON Schema, so it assigns directly. - parameters: tool.parameters, - })) - - return { - ...options.system !== undefined ? { systemPrompt: options.system } : {}, - messages, - ...tools !== undefined && tools.length > 0 ? { tools } : {}, - } + return piContext(options, messages) } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index ab08f21b81..2b0d842e47 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -36,6 +36,10 @@ export const inject = ['llm'] /** Register one generic pi-ai adapter for all configured provider routes. */ export function apply(ctx: Context, config: Config): void { const profiles = resolveProfiles(config.providers) - const adapter = new PiAiAdapter({ profiles }) + const attachments = ctx.get('attachments') + const adapter = new PiAiAdapter({ + profiles, + ...(attachments === undefined ? {} : { attachments }), + }) ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter) } diff --git a/packages/llm/llm-pi-ai/src/replay.ts b/packages/llm/llm-pi-ai/src/replay.ts index 4e4fe679a5..a4ff9fb9b6 100644 --- a/packages/llm/llm-pi-ai/src/replay.ts +++ b/packages/llm/llm-pi-ai/src/replay.ts @@ -134,6 +134,8 @@ function foreignAssistant(message: Message): AssistantMessage { name: block.name, arguments: parseArguments(block.arguments), }); break + case 'image': + throw new LlmError('pi-ai chat history cannot represent structured assistant image output', 'UNSUPPORTED_CONTENT') default: // plugin-added block types are not representable in pi-ai. break diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index f37b07f624..63e30c0ed5 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -320,6 +320,7 @@ describe('provider profile lifecycle', () => { const models = await ctx.llm.listModels('openai') expect(models.find(model => model.id === 'gpt-4.1')).toEqual({ provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1', + inputModalities: ['text', 'image'], outputModalities: ['text'], }) expect(models.every(model => model.provider === 'openai')).toBe(true) const context = await ctx.llm.resolveModelContext('openai', 'gpt-4.1') diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 15471875d2..23f0cd78ad 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' import { toPiContext } from '../src/context.ts' @@ -63,6 +65,48 @@ describe('toPiContext', () => { expect(context.tools).toBeUndefined() }) + it('resolves durable image references into native pi-ai image content', async () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 3, + width: 1, + height: 1, + } + const readImage = vi.fn().mockResolvedValue({ ref: attachment, data: Uint8Array.of(1, 2, 3) }) + const context = await toPiContext({ + provider: 'openai', + model: 'gpt-4.1', + messages: [{ + role: 'user', + content: [{ type: 'text', text: 'describe' }, { type: 'image', attachment }], + }], + }, { readImage } as unknown as AttachmentStore) + + expect(readImage).toHaveBeenCalledWith(attachment) + expect(context.messages[0]).toEqual({ + role: 'user', + content: [ + { type: 'text', text: 'describe' }, + { type: 'image', data: 'AQID', mimeType: 'image/png' }, + ], + timestamp: 0, + }) + }) + + it('rejects structured image history when no durable resolver is supplied', () => { + expect(() => toPiContext({ + provider: 'openai', model: 'gpt-4.1', + messages: [{ role: 'user', content: [{ + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + mediaType: 'image/png', bytes: 1, width: 1, height: 1, + }, + }] }], + })).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + }) + it('maps assistant text/reasoning/tool-call blocks', () => { const context = toPiContext({ provider: 'deepseek', diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index 45c2af21a5..91af731a0f 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../attachment/attachment" + }, { "path": "../../support/invariants" }, diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index cc13bc183e..a518647aa3 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -36,11 +36,13 @@ ], "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", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 6765833e8c..075208bfe8 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -235,6 +235,8 @@ export class LlmService extends Service { id: model.id, name: model.name, ...model.description === undefined ? {} : { description: model.description }, + ...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] }, + ...model.outputModalities === undefined ? {} : { outputModalities: [...model.outputModalities] }, } }) } diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 12febecf42..325138ab02 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -5,6 +5,7 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { CallId, ProviderRequestId } from './brand.ts' /** Serializable provider-boundary facts; policy decides whether they are retryable. */ @@ -33,6 +34,15 @@ export interface ReasoningBlock { text: string } +/** A durable raster image reference, valid in user or assistant content. */ +export interface ImageBlock { + type: 'image' + /** Immutable bytes and intrinsic display metadata owned by the attachment service. */ + attachment: ImageAttachmentRef + /** Optional provider- and UI-facing alternative text. */ + alt?: string +} + /** A tool invocation requested by the model. */ export interface ToolCallBlock { type: 'tool-call' @@ -58,6 +68,7 @@ export interface ToolResultBlock { export interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock + 'image': ImageBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } @@ -143,6 +154,15 @@ export interface LlmProviderInfo { name: string } +/** Merge-extensible provider model modality vocabulary. */ +export interface ModelModalityMap { + text: 'text' + image: 'image' +} + +/** Any declared provider model modality. */ +export type ModelModality = ModelModalityMap[keyof ModelModalityMap] + /** One adapter-discovered model; catalog membership is advisory, not request validation. */ export interface LlmModelInfo { /** Provider route that owns this model entry. */ @@ -153,6 +173,10 @@ export interface LlmModelInfo { name: string /** Optional user-facing distinction from otherwise similar models. */ description?: string + /** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */ + inputModalities?: readonly ModelModality[] + /** Structured response modalities; absent means unknown, while an explicit omission is negative capability. */ + outputModalities?: readonly ModelModality[] } /** Provider-owned context capacity for one exact provider/model route. */ diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json index 5bc7a9fcf5..fa9af39002 100644 --- a/packages/llm/llm/tsconfig.json +++ b/packages/llm/llm/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../util/brand" }, + { + "path": "../../attachment/attachment" + }, { "path": "../../support/invariants" } diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index e17b0c06b6..7a22c6dfbe 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -25,6 +25,11 @@ const CHARS_PER_TOKEN = 4 /** Per-block structural overhead for JSON framing and type tags. */ const BLOCK_OVERHEAD = 4 +/** Provider-neutral visual estimate: base cost plus one cost unit per 512px tile. */ +const IMAGE_BASE_TOKENS = 85 +const IMAGE_TILE_TOKENS = 170 +const IMAGE_TILE_EDGE = 512 + /** Role-field framing overhead added to every priced message. */ const ROLE_OVERHEAD = 4 @@ -357,6 +362,12 @@ export class TokenMeterService extends Service { case 'reasoning': tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD break + case 'image': { + const tiles = Math.ceil(block.attachment.width / IMAGE_TILE_EDGE) + * Math.ceil(block.attachment.height / IMAGE_TILE_EDGE) + tokens += IMAGE_BASE_TOKENS + tiles * IMAGE_TILE_TOKENS + BLOCK_OVERHEAD + break + } case 'tool-call': tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN) + Math.ceil(block.arguments.length / CHARS_PER_TOKEN) diff --git a/packages/ui/acp/src/codec.ts b/packages/ui/acp/src/codec.ts index 91453e3387..dfb7ba4e2b 100644 --- a/packages/ui/acp/src/codec.ts +++ b/packages/ui/acp/src/codec.ts @@ -56,6 +56,8 @@ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | switch (block.type) { case 'text': return { type: 'text', text: block.text } + case 'image': + return { type: 'text', text: `[image attachment ${block.attachment.attachmentId}]` } // reasoning → streamed as agent_thought_chunk, not a message block // tool-call / tool-result → the tool_call / tool_call_update path // plugin-added block types → not surfaced diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9b18ff2e5..d088f4922d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -362,6 +362,37 @@ importers: specifier: 1.1.0 version: 1.1.0 + packages/attachment/attachment: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/attachment/attachment-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../attachment + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/bash/bash: devDependencies: '@deepseek-ai/dsh-invariants': @@ -486,6 +517,9 @@ importers: packages/client/connection: dependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy @@ -521,6 +555,9 @@ importers: packages/client/runtime: dependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -555,6 +592,9 @@ importers: packages/client/ui-conversation: dependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-client-i18n': specifier: workspace:^ version: link:../i18n @@ -1917,6 +1957,9 @@ importers: packages/host/apiproxy: dependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -1960,6 +2003,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-attachment-local': + specifier: workspace:^ + version: link:../../attachment/attachment-local '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local @@ -2005,6 +2051,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-llm-pi-ai': + specifier: workspace:^ + version: link:../../llm/llm-pi-ai '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2096,6 +2145,9 @@ importers: packages/llm/llm: devDependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -2134,6 +2186,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4131,6 +4186,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/ui/app-boot + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../packages/attachment/attachment '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../packages/bash/bash diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index dbafb21881..d3762a4855 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -13,6 +13,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 40f1a22377..4b4a62c2bd 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -57,6 +57,9 @@ export const LINK_MAP: Record = { ApprovalPolicy: 'approval.md', ApprovalRequest: 'approval.md', ApprovalService: 'approval.md', + ImageAttachmentRef: 'attachment.md', + SaveImageAttachment: 'attachment.md', + StoredImageAttachment: 'attachment.md', BashExecRequest: 'bash.md', BashExecSpec: 'bash.md', BashProcess: 'bash.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index c93f70cf81..4e92ffdb97 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -56,6 +56,7 @@ type EventReceiverKind = 'context' | 'agent-dispatch' | 'events-service' const GROUP_ORDER = [ 'util', + 'attachment', 'llm', 'core', 'goal', @@ -82,6 +83,15 @@ const GROUP_ORDER = [ ] const SERVICE_ROLES: ServiceRole[] = [ + { + key: 'attachments', + pkg: 'attachment', + title: 'Durable binary attachment storage', + mode: 'seam', + implementations: ['attachment-local'], + consumers: ['host-runtime', 'llm-pi-ai'], + note: 'The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content.', + }, { key: 'llm', pkg: 'llm', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f95f47e580..1c223839b3 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -609,6 +609,31 @@ "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" }, + { + "doc": "docs/core-data-structures/attachment.md", + "symbol": "ImageMediaType", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/core-data-structures/attachment.md", + "symbol": "ImageAttachmentRef", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/core-data-structures/attachment.md", + "symbol": "ImageAttachmentLimits", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/core-data-structures/attachment.md", + "symbol": "SaveImageAttachment", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/core-data-structures/attachment.md", + "symbol": "StoredImageAttachment", + "source": "packages/attachment/attachment/src/types.ts" + }, { "doc": "docs/core-data-structures/bash.md", "symbol": "DshEnvironmentKey", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 6fc46dbdb8..ee2afb4094 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -41,6 +41,8 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { * blocks. A package moves on or off this list with its context behavior. */ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { + 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, + 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, 'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, 'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 2018a1c747..47406bd02a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -66,6 +66,7 @@ "./packages/tasks/*/src/invariant.ts", "./packages/workflow/*/src/invariant.ts", "./packages/web/*/src/invariant.ts", + "./packages/attachment/*/src/invariant.ts", "./packages/spill/*/src/invariant.ts", "./packages/timeout/*/src/invariant.ts", "./packages/todo/*/src/invariant.ts", @@ -131,6 +132,7 @@ "./packages/tasks/*/src", "./packages/workflow/*/src", "./packages/web/*/src", + "./packages/attachment/*/src", "./packages/spill/*/src", "./packages/timeout/*/src", "./packages/todo/*/src", From 580e05b7943a9bf768b3fa17b42cff05ba0137c5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 19:38:37 +0800 Subject: [PATCH 002/229] fix(gui): harden multimodal image attachments --- .../2026-07-05-reconstructable-requests.md | 8 +- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 61 +++-- ...-image-input-and-durable-attachments.zh.md | 61 +++-- apps/cli/src/web.ts | 16 +- apps/web/tests/smoke-fixture.e2e.ts | 2 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/src/image.ts | 23 +- .../attachment/attachment-local/src/index.ts | 2 +- .../attachment/attachment-local/src/store.ts | 36 ++- .../attachment-local/tests/image.spec.ts | 93 ++++++++ .../attachment-local/tests/index.spec.ts | 39 ++++ .../attachment-local/tests/store.spec.ts | 53 ++++- packages/client/connection/README.md | 2 +- packages/client/connection/src/client/api.ts | 4 + .../connection/src/client/connection.ts | 11 +- .../client/connection/src/client/index.ts | 2 +- .../connection/tests/connection.spec.ts | 32 +++ .../client/connection/tests/fixture.spec.ts | 25 +++ packages/client/runtime/README.md | 2 +- packages/client/runtime/src/client/index.ts | 1 + .../runtime/src/client/sessions/service.ts | 19 +- .../client/runtime/tests/client-apply.spec.ts | 2 + packages/client/ui-conversation/README.md | 2 + .../ui-conversation/src/client/apply.ts | 15 +- .../src/client/chat/AssistantMarkdown.tsx | 4 +- .../src/client/contract/slots.ts | 11 +- .../ui-conversation/src/client/service.ts | 82 ++++++- .../src/client/skeleton/ConversationRoot.tsx | 9 +- .../src/client/skeleton/EmptyState.tsx | 31 ++- .../src/client/skeleton/InputBar.tsx | 36 ++- .../tests/apply-inject.spec.tsx | 10 +- .../ui-conversation/tests/input-bar.spec.tsx | 30 ++- .../tests/message-image.spec.tsx | 20 ++ .../tests/service-orchestration.spec.ts | 137 ++++++++++- .../tests/skeleton-branches.spec.tsx | 25 ++- .../ui-conversation/tests/skeleton.spec.tsx | 62 ++++- .../client/ui-trajectory/tests/views.spec.tsx | 1 + packages/compact/compact-basic/README.md | 4 +- .../compact/compact-basic/src/summarizer.ts | 17 +- .../compact-basic/tests/compact-basic.spec.ts | 55 ++++- packages/host/runtime/src/api-proxy.ts | 7 +- .../host/runtime/tests/host-runtime.spec.ts | 212 +++++++++++++++++- packages/host/webserver/README.md | 2 +- packages/host/webserver/src/index.ts | 43 +++- .../host/webserver/tests/webserver.spec.ts | 78 ++++++- packages/llm/llm-pi-ai/README.md | 2 + packages/llm/llm-pi-ai/src/adapter.ts | 15 +- packages/llm/llm-pi-ai/src/index.ts | 3 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 96 ++++++++ packages/llm/llm-pi-ai/tests/context.spec.ts | 207 +++++++++++++++++ .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 74 +++++- .../llm/token-meter/tests/token-meter.spec.ts | 13 +- packages/ui/acp/tests/codec.spec.ts | 12 + packages/ui/tui/README.md | 2 +- packages/ui/tui/src/index.ts | 16 +- packages/ui/tui/tests/tui.spec.ts | 46 ++++ scripts/test-invariants.ts | 27 +++ 61 files changed, 1700 insertions(+), 214 deletions(-) rename .agents/notes/{proposed => implemented}/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml (82%) rename .agents/notes/{proposed => implemented}/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md (69%) rename .agents/notes/{proposed => implemented}/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md (70%) create mode 100644 packages/attachment/attachment-local/tests/image.spec.ts create mode 100644 packages/attachment/attachment-local/tests/index.spec.ts create mode 100644 packages/llm/llm-pi-ai/tests/context.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index 99a7633c5b..9670fcf0f7 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -1,4 +1,4 @@ -# Agent Note: Every LLM request is reconstructable from the session log +# Agent Note: Every LLM request is reconstructable from durable session state Status: implemented @@ -12,7 +12,7 @@ The reference shape for the happy path is MiniCode's `LLMClient`: a stateful con ### The principle -**Model-visible ⟺ logged.** Anything that reaches a model request must be recorded in the session log. The checkable consequence: **every conversation request the loop sends is a pure function of the session log** — anyone holding the log reconstructs it byte-for-byte. Scope, stated precisely: the guarantee covers the loop-built `GenerateOptions`; provider wire bytes follow from it because both adapters' serialization is a pure per-message function at a pinned code version; direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`) and their input is deterministic code over the logged region — reconstructable from log + code, outside the invariant because only the loop marks request ownership. +**Model-visible ⟺ durably recorded.** Text and structured references that reach a model request are recorded in the session log; referenced binary bytes live in immutable content-addressed attachment storage and are integrity-checked against the logged metadata. The checkable consequence: **every conversation `GenerateOptions` the loop sends is a pure function of the session log**, while provider wire bytes are a function of that envelope plus the immutable attachment objects at a pinned code version. Text-only adapter serialization remains pure in-memory conversion; image-capable adapters perform verified `readImage()` I/O during serialization. Direct one-shots such as compaction log their envelope scalars (`compact/summary.{provider, model, maxTokens}`), derive input deterministically from the logged region, and resolve any referenced images through the same durable store; they remain outside the loop invariant because only the loop marks request ownership. Prefix-cache stability is corollary #1, not the headline: an append-only log projected by a per-node pure function yields requests that are append-extensions of their predecessors whenever the header is unchanged — stability is emergent, not managed. Byte-exact audit/replay is corollary #2; resume and fork with *attributable* drift is corollary #3. @@ -26,7 +26,7 @@ Each step rebuilds prompt assembly. On the instance's first step, `agent/session **`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. -**Enforcement.** The `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and, when selected, independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. The loop records the exact frozen request through `markAgentLoopRequest()` in `dsh-llm`; the process-local identity lets the companion and other request observers recognize conversation work, while direct one-shots remain excluded regardless of their frozen shape or session id. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. +**Enforcement.** The `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and, when selected, independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Attachment backends separately verify referenced bytes against their content digest and logged media metadata before an adapter may serialize them. The loop records the exact frozen request through `markAgentLoopRequest()` in `dsh-llm`; the process-local identity lets the companion and other request observers recognize conversation work, while direct one-shots remain excluded regardless of their frozen shape or session id. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. ### The MiniCode shape: adopted, with the provenance arrow inverted @@ -44,7 +44,7 @@ Like MiniCode, the conversation advances append-only and resets only when model- ## Consequences -- A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event. +- A request envelope that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event. Exact replay of an image request additionally requires the immutable object named by its logged attachment reference. - Choosing between the advisory channels is a change-frequency decision, and the design makes the stable one structural: an `agent/session-prefix` contribution is composed once per loop instance and reused verbatim, so it extends the cacheable prefix at zero marginal cost and CANNOT bust the provider cache mid-session; content that changes mid-session flows through the append-only history channels — `agent.inject()` and tool/prompt-submit `additionalContexts` — each a durable `context/message` paid once and prefix-cached thereafter, at the price of accumulating in history and the log. Route session-frozen openers to the prefix and change notices to the history channels; a per-step request-only tail slot was deliberately dropped (no consumer, and a durable append covers every current update pattern). - What still costs full price at the provider is inherent and logged: compaction (its `compact/*` events and replacement entry), a real prompt, tool, or config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). The provider's own reasoning-content exclusion is managed server-side. - The `step/start`-listener behavior change (above) is the one observable semantics change for plugins; `agent/pre-step` is the current-request seam. diff --git a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml similarity index 82% rename from .agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml rename to .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 43f81f5752..5d438dfc9d 100644 --- a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 14ad4e5ceb28fd7df8b639db403be8accd8c2028 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 5752247b080ee1be0b0bfbbf7a9fb7486ff6c41b +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 156bec4a4f9bb05490847cbe775368bc472a33e9 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: fb0f8d3031bb2d0e6af97136892cd07c22d575b3 diff --git a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md similarity index 69% rename from .agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md rename to .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 14ad4e5ceb..156bec4a4f 100644 --- a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -1,12 +1,12 @@ # Agent Note: Web multimodal image input and durable attachments -Status: proposed +Status: implemented English | [中文](2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md) ## Problem -The Web composer accepts only text: `InputBar` receives a string draft, `ConversationService.send()` creates text content, and the host forwards that content to the agent. Users cannot paste an image, inspect it before sending, submit an image-only prompt, or recover sent images from history. +Before this change, the Web composer accepted only text: `InputBar` received a string draft, `ConversationService.send()` created text content, and the host forwarded that content to the agent. Users could not paste an image, inspect it before sending, submit an image-only prompt, or recover sent images from history. This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../implemented/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content. @@ -14,19 +14,19 @@ The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web- Peer products converge on an attachment rail above the editor, but their storage choices differ. Codex-style paths such as `/var/folders/.../codex-clipboard-*.png` are reasonable intake staging locations, not durable message identities: the operating system may delete them, another host cannot read them, and a resumed session cannot rely on them. -## Proposal +## Decision -Add pasted or dropped raster images to the Web composer as the first consumer of a durable attachment capability. Unsent files remain temporary client-owned draft state. The host validates and durably commits every accepted user image before appending its message event. A provider adapter that produces structured image output must durably commit the output before appending its assistant block. Canonical user and assistant content contains only role-neutral `ImageBlock` references. +Pasted or dropped raster images are the Web composer's first consumer of a durable attachment capability. Unsent files remain temporary client-owned draft state. The host validates and durably commits every accepted user image before appending its message event. A provider adapter that produces structured image output must durably commit the output before appending its assistant block. Canonical user and assistant content contains only role-neutral `ImageBlock` references. -Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-only or mixed prompts, historical user and assistant image rendering, and original-image preview on double-click. File picking, generic files, PDF, audio, video, image copying, and a custom context menu are separate follow-ups. +Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-only or mixed prompts, historical user and assistant image rendering, and original-image preview on double-click. File picking, generic files, PDF, audio, video, image copying, and a custom context menu remain separate follow-ups. ### Product behavior - Pasting or dropping one or more supported images adds ordered thumbnails above the textarea without inserting placeholder text. Dragging files over the composer highlights the drop target. - The rail is shared by the empty-state and resident composers, is hidden when empty, and scrolls horizontally instead of widening the composer. - Each approximately 72-by-72-pixel thumbnail has a remove action and opens its original draft image on double-click. -- A prompt may contain text and images or images only. Pure text paste remains native browser behavior; the paste handler prevents the default only when it accepts an image file. File drops on the composer always prevent browser navigation, accept supported images, and report unsupported files locally. -- A failed send restores the complete text and image draft. Removal, successful send, and session-scope disposal revoke obsolete object URLs. +- A prompt may contain text and images or images only. Pure text paste remains native browser behavior; mixed clipboard content inserts its text normally while adding its files to the rail, and file-only paste prevents default browser handling. File drops on the composer always prevent browser navigation and report unsupported files locally. +- A failed send restores the complete text and image draft. Removal, successful send, empty-state disposal, rendered-session disposal, and application disposal revoke the object URLs they own. - Historical user and assistant images use one `MessageImage` control. Inline images preserve intrinsic aspect ratio, do not upscale, and stay within a 240-by-240-pixel box. - Double-clicking a message image opens the stored original in a viewport-bounded modal. Escape, the close control, and backdrop activation close it and restore focus. - Version one does not override the browser context menu and provides no explicit image-copy action. @@ -54,6 +54,7 @@ interface ChatStoreState { } interface ComposerAttachment { + kind: 'image' id: string file: File previewUrl: string @@ -64,7 +65,7 @@ This split uses the slots framework's store seat and bound actions as the single The local attachment backend resolves an explicit `dshHome`, then `$DSH_HOME`, then `~/.dsh`. It stores content-addressed objects below `$DSH_HOME/attachments/v1/objects//` with owner-only directory and file permissions. A temporary file is written, synchronized, and atomically published before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier, and every read verifies the digest, media type, byte length, width, and height. -The store performs no automatic deletion in version one. Sent user images and model-generated images remain reachable for history, resume, and fork. Reference-aware garbage collection needs a separate design because an age-only rule can delete data still referenced by a durable session. +The store performs no automatic deletion in version one. Sent user images and model-generated images remain reachable for history, resume, and fork. Reference-aware garbage collection needs a separate design because an age-only rule can delete data still referenced by a durable session. Deployment byte and pixel limits are admission policy on writes; reads verify the digest and recorded metadata without reapplying current admission limits, so lowering policy does not invalidate older history. ### Durable content and prompt wire @@ -90,7 +91,7 @@ interface ImageBlock { } ``` -`ImageBlock` joins the merge-extensible core `ContentBlockMap` and is valid in either user or assistant content. It never carries base64, an object URL, a filesystem path, or a provider-owned locator. This keeps the session event plus immutable object store sufficient to reconstruct the exact model-visible image. +`ImageBlock` joins the merge-extensible core `ContentBlockMap` and is valid in either user or assistant content. It never carries base64, an object URL, a filesystem path, or a provider-owned locator. This keeps the session event plus immutable object store sufficient to reconstruct the exact model-visible image. The LLM vocabulary therefore has a type-only dependency on the attachment seam; provider runtime dependencies remain adapter-specific. The browser cannot mint a durable reference, so `session.prompt` accepts a narrow intake union rather than canonical `ContentBlock[]`: @@ -109,29 +110,31 @@ type PromptInputPart = Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, magic-byte MIME, intrinsic dimensions, and decoded-pixel count. Only after every image succeeds does it call the agent with normalized text and durable image blocks. A failure appends no user event and exposes no attachment path or raw bytes. -`session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client caches the resulting object URL by session and attachment identifier for its service lifetime and revokes it on disposal. +`session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client deduplicates loads by session and attachment identifier while that session is rendered, revokes resolved URLs on rendered-session disposal, and invalidates late loads so an unmounted session cannot repopulate the cache. ### Model capabilities and provider behavior Model catalog entries gain optional merge-extensible input and output modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. -The host is the authoritative preflight boundary. If the selected model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. Immediate intake-time rejection in the UI may be added after model selection is exposed consistently across every Web entry path. +The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. `host.describe` projects the default model and image limits into `SessionsService`; both composers use the limits before allocating object URLs or base64, while only the no-session composer uses the default model for early explicit text-only feedback. Decoded-pixel validation and every resident session's actual route remain authoritative on the host. -The Pi-AI adapter is the first visual-input route: it resolves each durable reference through `ctx.attachments` and emits native image content only for models that declare image input. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. +The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, then resolves each durable reference and emits native image content only for models that declare image input. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. Token estimation accounts for image dimensions without counting base64 or attachment locators as text. Provider-reported usage remains authoritative. ACP renders an explicit image marker until that protocol surface gains native image support rather than silently omitting the block. +Compaction replays the selected conversation prefix, including image references, into the configured summarization route. A visual-capable route resolves those references through its adapter; a text-only route fails explicitly instead of silently dropping the visual context. The synthesized checkpoint remains text-only, and `compact-basic` rejects image summary output with `UNSUPPORTED_CONTENT`. + ### History rendering and original preview -History folding preserves `ImageBlock` in both user and assistant messages. User images align to the trailing edge above their text; assistant images align to the leading narration flow. `MessageImage` derives a stable inline box from recorded dimensions, resolves bytes through the session-authorized loader, uses `object-fit: contain`, and turns a missing or corrupt object into a retryable error control. +History folding preserves `ImageBlock` in both user and assistant messages. User images align to the trailing edge above their text; assistant images remain in their original content-block position in the leading narration flow. `MessageImage` derives a stable inline box from recorded dimensions, resolves bytes through the session-authorized loader, uses `object-fit: contain`, and turns a missing or corrupt object into a retryable error control. Composer thumbnails and each `MessageImage` own ephemeral original-preview state and invoke the same pure `ImageLightbox`. The modal uses the already resolved original object URL, constrains only display size, focuses its close control, and restores the previous focus target when closed. ### Limits and trust boundaries -Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and are projected to the client for fast-path guidance; host validation remains authoritative. +Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and are projected to the client for fast-path guidance; host validation remains authoritative. The Web carrier independently caps buffered API request bodies, with `dsh web` deriving its default from the aggregate image limit plus base64/envelope expansion and allowing an explicit override. Malformed base64, unsupported or mismatched media, truncated headers, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. @@ -144,19 +147,18 @@ Malformed base64, unsupported or mismatched media, truncated headers, excess byt | `packages/llm/llm` and `packages/llm/token-meter` | Role-neutral `ImageBlock`, modality metadata, and image cost estimation. | | `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. | | `packages/llm/llm-deepseek` | Reject image content explicitly. | +| `packages/compact/compact-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. | | `packages/host/apiproxy` and `packages/host/runtime` | Narrow upload wire, persist-before-event ordering, session-authorized reads, limits, and model preflight. | +| `packages/host/webserver` | Bound buffered API request bodies before the fetch carrier. | | `packages/client/connection` and `packages/client/runtime` | Wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. | | `packages/client/ui-conversation` | Per-session draft images, attachment rail, user and assistant image controls, and original preview. | | `packages/ui/acp` | Explicit fallback rendering for image blocks. | The attachment packages form the interface/implementation side of one capability seam. Composer behavior stays in the conversation object layer, provider conversion stays in adapters, and no change is required in `agent-loop`. -### Delivery +### Implementation -1. Land the attachment seam, role-neutral image block, image-aware token estimation, Pi-AI input conversion, DeepSeek rejection, and durable host ordering. -2. Land the Web upload/read protocol, in-memory draft images, paste/drop rail, user and assistant history rendering, double-click preview, and assembled keyless Web coverage. -3. Add immediate intake-time capability feedback when active model selection is consistently available to the composer. -4. Propose file picking, generic files/PDF, audio/video, durable draft staging, output-provider certification, and reference-aware garbage collection independently. +The implemented slice includes the attachment seam, role-neutral image block, image-aware token estimation, Pi-AI input conversion, DeepSeek rejection, durable host ordering, Web upload/read protocol, host-capability projection, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, double-click preview, compaction handling, and keyless assembled Web coverage. No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. @@ -186,20 +188,16 @@ Composer presentation can use a generic attachment rail, but provider semantics UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalogued model paths. Silent filtering changes user intent. Provider enforcement remains mandatory, while UI checks are optional earlier feedback. -## Acceptance criteria +## Testing -- Pasting or dropping one or more supported images shows ordered removable thumbnails above both composer variants without changing textarea text; drag-over highlights the target, unsupported drops cannot navigate away, and image-only send works. -- Unsent browser images exist only as `File` and object URLs, survive session switches in memory, do not enter `localStorage`, and are revoked after removal, accepted send, or service disposal. -- Every accepted user image is committed below resolved `DSH_HOME` before its `user/message` event. The event contains only `ImageBlock` references and never base64 or temporary paths. -- Structured assistant images can be represented only by a durable `ImageBlock`; a future output adapter must persist bytes before emitting the assistant event, while Markdown image URLs remain text. -- Cold history renders user and assistant image references through the same bounded control. Double-click opens the original; Escape, backdrop, and close control dismiss it without a custom context menu. -- Session attachment reads fail unless the same session log references the identifier. Missing or corrupt objects fail explicitly and never return unverified bytes. -- Pi-AI emits native input images for a compatible route. DeepSeek and every non-implementing consumer return an explicit unsupported-content failure rather than dropping the block. -- An explicitly text-only active model rejects image send before attachment persistence or session event append; unknown metadata still reaches adapter enforcement and a failed send restores the draft. -- Keyless unit, host integration, client integration, and assembled Chromium coverage exercise persistence ordering, absence of base64 in logs, authorization, paste and drop, image-only send, historical user and assistant images, original preview, and object-URL cleanup. -- The current production adapter set declares text-only output; output-provider certification, file picking, non-image files, video, persistent drafts, and garbage collection remain outside version one. +- Storage tests cover content-addressed deduplication, private permissions, admission failures, corruption/missing-object failures, and reading history after deployment limits are lowered. +- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, and bounded HTTP request bodies. +- Client unit and assembled Chromium tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, historical user and assistant images, original preview, ordering, and draft/session/application object-URL cleanup. +- Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, nested tool-result images, preserved summary input, and explicit image-output rejection. +- A credentialed real-API test sends a PNG through the Anthropic `claude-opus-4-8` route and requires the model to identify its QR code. +- The current production adapter set declares text-only output; output-provider certification remains outside version one. -## Risks +## Consequences - Durable storage grows without garbage collection. Version one chooses replay safety over premature deletion. - A missing or corrupt object makes exact model reconstruction fail. Failing loud preserves integrity but may prevent that session from continuing until repaired. @@ -208,3 +206,4 @@ UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalog - Original preview decodes more pixels than the inline control displays. Pixel limits, one clicked preview, and object-URL disposal bound but do not eliminate transient browser memory. - Capability metadata may be missing or stale. Host preflight improves feedback, while adapter enforcement remains authoritative. - A future output provider may require authenticated retrieval before an assistant image can complete, adding latency and a new failure point. Persist-before-event ordering favors replay integrity. +- File picking, generic files/PDF, audio/video, durable draft staging, image copying, custom context menus, output-provider certification, and reference-aware garbage collection remain independent designs. diff --git a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md similarity index 70% rename from .agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md rename to .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index 5752247b08..fb0f8d3031 100644 --- a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -1,12 +1,12 @@ # Agent Note: Web 多模态图片输入与持久附件 -Status: proposed +Status: implemented [English](2026-07-22-web-multimodal-image-input-and-durable-attachments.md) | 中文 ## 问题 -Web 输入区目前仅接受文本:`InputBar` 接收字符串草稿,`ConversationService.send()` 创建文本内容,宿主再把该内容转发给 agent(智能体)。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词,也无法从历史记录中恢复已发送图片。 +在此变更之前,Web 输入区仅接受文本:`InputBar` 接收字符串草稿,`ConversationService.send()` 创建文本内容,宿主再把该内容转发给 agent(智能体)。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词,也无法从历史记录中恢复已发送图片。 这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../implemented/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 @@ -14,19 +14,19 @@ Web 输入区目前仅接受文本:`InputBar` 接收字符串草稿,`Convers 同类产品普遍在编辑器上方设置附件栏,但存储方案各不相同。诸如 `/var/folders/.../codex-clipboard-*.png` 的 Codex 式路径适合作为接收输入时的暂存位置,却不能作为持久消息身份:操作系统可能删除文件,另一台宿主无法读取文件,恢复后的会话也不能依赖文件仍然存在。 -## 提案 +## 决策 -把粘贴或拖放的光栅图片作为持久附件能力的首个消费方,接入 Web 输入区。未发送文件仍是由客户端持有的临时草稿状态。宿主在追加相应消息事件前,校验并持久提交每张已接受的用户图片。生成结构化图片输出的提供方适配器在追加相应助手块前,也必须持久提交输出。规范用户内容与助手内容只包含角色无关的 `ImageBlock` 引用。 +粘贴或拖放的光栅图片是 Web 输入区对持久附件能力的首个应用场景。未发送文件仍是由客户端持有的临时草稿状态。宿主在追加相应消息事件前,校验并持久提交每张已接受的用户图片。生成结构化图片输出的提供方适配器在追加相应助手块前,也必须持久提交输出。规范用户内容与助手内容只包含角色无关的 `ImageBlock` 引用。 -第一版支持粘贴和拖放 PNG、JPEG、WebP 与 GIF,支持仅图片或混合提示词,支持渲染历史用户图片与助手图片,并支持双击预览原图。文件选择、通用文件、PDF、音频、视频、图片复制和自定义上下文菜单分别作为后续工作。 +第一版支持粘贴和拖放 PNG、JPEG、WebP 与 GIF,支持仅图片或混合提示词,支持渲染历史用户图片与助手图片,并支持双击预览原图。文件选择、通用文件、PDF、音频、视频、图片复制和自定义上下文菜单仍分别作为后续工作。 ### 产品行为 - 粘贴或拖放一张或多张受支持的图片后,文本框上方会按顺序显示缩略图,但不会插入占位文本。文件拖入输入区时会高亮放置目标。 - 空状态输入区与常驻输入区共用附件栏;附件栏为空时隐藏,通过横向滚动避免撑宽输入区。 - 每个缩略图约为 72 × 72 像素,带有移除操作;双击时打开草稿原图。 -- 提示词可同时包含文本与图片,也可仅包含图片。粘贴纯文本时保持浏览器原生行为;粘贴处理器只有接受图片文件后才阻止默认行为。无论文件是否受支持,在输入区放置文件时都会阻止浏览器导航;系统会接受受支持的图片,并在本地提示哪些文件不受支持。 -- 发送失败时恢复完整的文本与图片草稿。移除、发送成功和会话作用域释放都会撤销过期的对象 URL。 +- 提示词可同时包含文本与图片,也可仅包含图片。粘贴纯文本时保持浏览器原生行为;粘贴混合的剪贴板内容时,文本会正常插入,文件则同时添加到附件栏;仅粘贴文件时才阻止浏览器的默认处理。在输入区放置文件时总会阻止浏览器导航,并在本地报告不受支持的文件。 +- 发送失败时恢复完整的文本与图片草稿。移除、发送成功、空状态释放、已渲染会话释放和应用释放都会撤销各自持有的对象 URL。 - 历史用户图片与助手图片共用一个 `MessageImage` 控件。行内图片保持固有宽高比、不放大,并限制在 240 × 240 像素的边界框内。 - 双击消息图片会在不超出视口的模态框中打开存储的原图。按 Escape、激活关闭控件或激活背景区域都会关闭模态框并恢复焦点。 - 第一版不覆盖浏览器上下文菜单,也不提供明确的图片复制操作。 @@ -54,6 +54,7 @@ interface ChatStoreState { } interface ComposerAttachment { + kind: 'image' id: string file: File previewUrl: string @@ -64,7 +65,7 @@ interface ComposerAttachment { 本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。服务先写入并同步临时文件,再以原子方式发布,之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中,每次读取都会校验摘要、媒体类型、字节长度、宽度和高度。 -第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。 +第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。部署的字节和像素限制是写入时的准入策略;读取时会校验摘要和已记录的元数据,但不重新应用当前准入限制,因此收紧策略不会导致旧历史记录失效。 ### 持久内容与提示词协议 @@ -90,7 +91,7 @@ interface ImageBlock { } ``` -`ImageBlock` 加入可合并扩展的核心 `ContentBlockMap`,在用户内容和助手内容中都有效。它绝不携带 base64、对象 URL、文件系统路径或提供方持有的定位符。因此,会话事件与不可变对象存储足以共同重建模型可见的确切图片。 +`ImageBlock` 加入可合并扩展的核心 `ContentBlockMap`,在用户内容和助手内容中都有效。它绝不携带 base64、对象 URL、文件系统路径或提供方持有的定位符。因此,会话事件与不可变对象存储足以共同重建模型可见的确切图片。LLM 词汇由此仅在类型层面依赖附件服务边界;提供方运行时依赖仍由各适配器持有。 浏览器无法生成持久引用,因此 `session.prompt` 接受范围狭窄的接收联合类型,而不是规范 `ContentBlock[]`: @@ -109,29 +110,31 @@ type PromptInputPart = Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和解码像素数。只有每张图片都成功后,宿主才会用规范化文本和持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 -`session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。客户端在服务生命周期内,以会话和附件标识符为键缓存生成的对象 URL,并在释放时撤销它。 +`session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并使延迟完成的加载失效,以免已卸载的会话重新写入缓存。 ### 模型能力与提供方行为 模型目录项增加可选且可合并扩展的输入与输出模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。如果所选模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。在所有 Web 入口路径都能一致公开模型选择后,可以在 UI 中增加粘贴时立即拒绝的反馈。 +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。`host.describe` 把默认模型和图片限制投影到 `SessionsService`;两种输入区都会在分配对象 URL 或 base64 前使用这些限制,只有无会话输入区会使用默认模型,针对明确仅支持文本的情况提前反馈。解码像素校验与每个常驻会话的实际路由均由宿主作出权威判定。 -Pi-AI 适配器是首条视觉输入路径:它通过 `ctx.attachments` 解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 +Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为文本计数。提供方返回的用量仍是权威值。在 ACP(Agent Client Protocol)接口原生支持图片前,ACP 会渲染明确的图片标记,而不是静默省略该块。 +压缩(compaction)会把选定的会话前缀(包含图片引用)回放到已配置的摘要生成路径中。支持视觉的路径会通过适配器解析这些引用;仅文本路径会明确失败,而不是静默丢弃视觉上下文。合成的检查点仍仅包含文本,`compact-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。 + ### 历史渲染与原图预览 -历史记录折叠会在用户消息和助手消息中保留 `ImageBlock`。用户图片在文本上方靠尾端对齐;助手图片在叙述流中靠前端对齐。`MessageImage` 根据记录的尺寸派生稳定的行内边界框,通过会话授权加载器解析字节,使用 `object-fit: contain`,并将对象缺失或损坏转换为可重试的错误控件。 +历史记录折叠会在用户消息和助手消息中保留 `ImageBlock`。用户图片在文本上方靠尾端对齐;助手图片则保留在靠前端叙述流中的原内容块位置。`MessageImage` 根据记录的尺寸派生稳定的行内边界框,通过会话授权加载器解析字节,使用 `object-fit: contain`,并将对象缺失或损坏转换为可重试的错误控件。 输入区缩略图和每个 `MessageImage` 各自持有临时原图预览状态,并调用同一个纯 `ImageLightbox`。模态框使用已经解析的原始对象 URL,只限制显示尺寸;它会聚焦关闭控件,并在关闭时把焦点恢复到先前的目标。 ### 限制与信任边界 -第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引;宿主校验仍是权威结果。 +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引;宿主校验仍是权威结果。Web 载体会独立限制 API 请求体的缓冲大小;`dsh web` 根据图片总量限制加上 base64 和请求封装的膨胀量推导默认值,并允许显式覆盖。 格式错误的 base64、不支持或不匹配的媒体、截断的文件头、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 @@ -144,19 +147,18 @@ token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为 | `packages/llm/llm` 和 `packages/llm/token-meter` | 角色无关的 `ImageBlock`、模态元数据和图片成本估算。 | | `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 | | `packages/llm/llm-deepseek` | 明确拒绝图片内容。 | +| `packages/compact/compact-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 | | `packages/host/apiproxy` 和 `packages/host/runtime` | 范围狭窄的上传协议、先持久化再追加事件的顺序、会话授权读取、限制和模型前置检查。 | +| `packages/host/webserver` | 在 fetch 载体之前限制 API 请求体的缓冲大小。 | | `packages/client/connection` 和 `packages/client/runtime` | 协议类型、fixture(测试前置数据)图片、提示词上传、附件读取和持久引用折叠。 | | `packages/client/ui-conversation` | 每个会话的草稿图片、附件栏、用户与助手图片控件和原图预览。 | | `packages/ui/acp` | 图片块的明确兜底渲染。 | 附件包(package)构成一个能力服务边界的接口与实现侧。输入区行为留在会话对象层,提供方转换留在适配器中,无需修改 `agent-loop`。 -### 交付 +### 实现 -1. 交付附件服务边界、角色无关的图片块、图片感知的 token 估算、Pi-AI 输入转换、DeepSeek 拒绝和宿主侧的持久化顺序。 -2. 交付 Web 上传与读取协议、内存草稿图片、支持粘贴与拖放的附件栏、用户与助手历史图片渲染、双击预览,以及组装后无需密钥的 Web 覆盖。 -3. 在输入区能够一致获取当前模型选择后,增加接收图片时立即提供的能力反馈。 -4. 分别为文件选择、通用文件与 PDF、音频与视频、持久草稿暂存、输出提供方认证和按引用感知的垃圾回收提出方案。 +已实现的范围包括附件服务边界、角色无关的图片块、图片感知的 token 估算、Pi-AI 输入转换、DeepSeek 拒绝、宿主持久化顺序、Web 上传与读取协议、宿主能力投影、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、双击预览、压缩处理,以及组装后无需密钥的 Web 覆盖。 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 @@ -186,20 +188,16 @@ token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为 UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模型的路径。静默过滤会改变用户意图。提供方强制检查仍是必需项,UI 检查则是可选的提前反馈。 -## 验收标准 +## 测试 -- 在两种输入区中粘贴或拖放一张或多张受支持的图片时,文本框上方会按顺序显示可移除缩略图,且不更改文本框内容;拖入时会高亮目标,放置不支持的文件也不会触发页面跳转,仅图片的发送可正常工作。 -- 未发送的浏览器图片仅以 `File` 与对象 URL 的形式存在,可以在内存中跨会话切换保留,不进入 `localStorage`,并在移除、发送被接受或服务释放后撤销。 -- 每张已接受的用户图片都会提交到解析所得 `DSH_HOME` 下,之后才会追加相应的 `user/message` 事件。事件只包含 `ImageBlock` 引用,绝不包含 base64 或临时路径。 -- 结构化助手图片只能由持久 `ImageBlock` 表示;未来的输出适配器必须在发出助手事件前持久化字节,而 Markdown 图片 URL 仍是文本。 -- 冷启动历史记录通过同一个有界控件渲染用户与助手图片引用。双击打开原图;Escape、激活背景区域和激活关闭控件可以关闭预览,且不提供自定义上下文菜单。 -- 除非同一个会话日志引用了该标识符,否则会话附件读取会失败。对象缺失或损坏会明确失败,绝不返回未经校验的字节。 -- Pi-AI 会为兼容路径生成提供方原生输入图片。DeepSeek 与所有未实现该能力的消费方返回明确的不支持内容错误,而不是丢弃该块。 -- 明确仅支持文本的当前模型会在持久化附件或追加会话事件前拒绝图片发送;未知元数据仍会到达适配器强制检查,发送失败则恢复草稿。 -- 无需密钥的单元测试、宿主集成测试、客户端集成测试和组装应用的 Chromium 覆盖会验证持久化顺序、日志中不含 base64、授权、粘贴与拖放、仅图片发送、历史用户与助手图片、原图预览和对象 URL 清理。 -- 当前生产适配器集合声明仅支持文本输出;输出提供方认证、文件选择、非图片文件、视频、持久草稿和垃圾回收不在第一版范围内。 +- 存储测试覆盖内容寻址去重、私有权限、准入失败、对象损坏或缺失时的失败,以及收紧部署限制后读取历史数据。 +- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制,以及大小受限的 HTTP 请求体。 +- 客户端单元测试和组装后的 Chromium 测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、历史用户与助手图片、原图预览、顺序,以及草稿、会话和应用层级的对象 URL 清理。 +- 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、嵌套工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。 +- 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG,并要求模型识别其中的二维码。 +- 当前生产适配器集合声明仅支持文本输出;输出提供方认证不在第一版范围内。 -## 风险 +## 后果 - 持久存储会在没有垃圾回收时持续增长。第一版选择回放安全,而不是过早删除。 - 对象缺失或损坏会让模型请求无法精确重建。明确失败可以保持完整性,但在修复前可能阻止该会话继续运行。 @@ -208,3 +206,4 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模 - 原图预览解码的像素多于行内控件显示的像素。像素限制、一次只打开一个预览和对象 URL 释放可以约束但无法消除浏览器瞬时内存占用。 - 能力元数据可能缺失或陈旧。宿主前置检查可以改善反馈,适配器强制检查仍是权威结果。 - 未来输出提供方可能需要经过身份认证的下载,助手图片才能完成,这会增加延迟与新的故障点。先持久化再追加事件的顺序优先保障回放完整性。 +- 文件选择、通用文件与 PDF、音频与视频、持久草稿暂存、图片复制、自定义上下文菜单、输出提供方认证和按引用感知的垃圾回收仍是相互独立的设计。 diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 02e98e78b5..99150d4c1d 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -12,6 +12,7 @@ import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-ho const LOOPBACK_HOST = '127.0.0.1' const ALL_INTERFACES_HOST = '0.0.0.0' +const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024 export async function runWeb(argv: string[]): Promise { const { values } = parseArgs({ @@ -19,6 +20,7 @@ export async function runWeb(argv: string[]): Promise { options: { host: { type: 'string', default: LOOPBACK_HOST }, port: { type: 'string', default: '3080' }, + 'max-request-body-bytes': { type: 'string' }, }, allowPositionals: false, }) @@ -34,9 +36,21 @@ export async function runWeb(argv: string[]): Promise { process.stderr.write(`dsh web: invalid --port ${values.port}\n`) process.exit(1) } + const configuredMaxRequestBodyBytes = values['max-request-body-bytes'] === undefined + ? undefined + : Number(values['max-request-body-bytes']) + if (configuredMaxRequestBodyBytes !== undefined + && (!Number.isInteger(configuredMaxRequestBodyBytes) || configuredMaxRequestBodyBytes < 1)) { + process.stderr.write(`dsh web: invalid --max-request-body-bytes ${values['max-request-body-bytes']}\n`) + process.exit(1) + } // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const host = await startHost({ boot: { persistenceRoot: './.sessions' } }) + const attachments = host.ctx.get('attachments') + if (attachments === undefined) throw new Error('dsh web: attachment service unavailable') + const maxRequestBodyBytes = configuredMaxRequestBodyBytes + ?? Math.ceil(attachments.imageLimits.maxMessageImageBytes * 4 / 3) + REQUEST_ENVELOPE_HEADROOM_BYTES // Web UI plugin chain: in-memory Loader tree over the eight UI packages, // then the registry that feeds __DSH_BOOT__ and /plugins//client.js. @@ -78,7 +92,7 @@ export async function runWeb(argv: string[]): Promise { let server: Awaited> try { server = await startWebServer( - { host: hostAddress, port, distIndex, apiHandler: host.handler, webPlugins }, + { host: hostAddress, port, distIndex, apiHandler: host.handler, maxRequestBodyBytes, webPlugins }, (err: Error) => { process.stderr.write(`dsh web: ${String(err)}\n`) void shutdown(1) diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 0e4b21d184..b0e56c9ea2 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -50,6 +50,7 @@ describe('web boot chain (keyless, real carrier)', () => { port, distIndex: DIST_INDEX, apiHandler, + maxRequestBodyBytes: 32 * 1024 * 1024, webPlugins: { snapshot: () => ROWS, clientPath: id => (id === ROWS[0]!.id ? LIVE_BUNDLE : undefined), @@ -108,6 +109,7 @@ describe('web boot chain success pass (keyless, production plugin chain, ?fixtur port, distIndex: DIST_INDEX, apiHandler, + maxRequestBodyBytes: 32 * 1024 * 1024, webPlugins: { snapshot: () => rows, clientPath: id => byId.get(id) }, }, (err) => { pageErrors.push(`server: ${String(err)}`) }) browser = await chromium.launch() diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index e3c52e2536..7d9a156757 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: 6ff2aa1ad4ca2ef051322f9d95631fe626d26e84 -architecture.zh.md: b4b26efec16d85f1fb26589c5c9bffbb35e39564 +architecture.md: c0cfb1bd78aa55596e04bcfd750b67e15c84094e +architecture.zh.md: 23156cfd84d0b1e6df86102874fc3f4aaa021fbc diff --git a/docs/architecture.md b/docs/architecture.md index 6ff2aa1ad4..c0cfb1bd78 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -143,7 +143,7 @@ Each agent owns a scoped `agent.ctx`; shared storage overlays global tool, promp The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events remain for replay and UI fidelity. Fork, resume, transcript rendering, telemetry, and persistence derive from the same stream. -**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, and headers by folding `request/header` — and the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). +**Model-visible ⟺ durably recorded**: the log reconstructs request envelopes from `step/start`, the header's session prefix, and folded `request/header` events; logged attachment references resolve through integrity-checked immutable objects. `dsh-agent-loop/invariant` asserts envelope reconstruction through `ctx.invariants`; attachment backends assert byte integrity ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). Durability is a plugin concern. Backends buffer synchronous `session/event` notifications. The semantic checkpoint policy drains requests before adapter dispatch, recorded top-level calls before tool dispatch, and complete response/result batches at `agent/post-step`; the loop retains the final turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index b4b26efec1..23156cfd84 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -143,7 +143,7 @@ forever: 会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件留在日志中,以保证回放和 UI 保真。fork、恢复、transcript(文本记录)渲染、遥测和持久化均派生自同一个事件流。 -**模型可见 ⟺ 已记录**:日志可以重建每个请求,包括由请求头会话前缀置于开头的 `step/start` 时消息,以及通过折叠 `request/header` 得到的请求头;开发期不变量会断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 +**模型可见 ⟺ 已持久记录**:日志根据 `step/start`、请求头的会话前缀和折叠后的 `request/header` 事件重建请求封装;日志中记录的附件引用通过经过完整性校验的不可变对象解析。`dsh-agent-loop/invariant` 通过 `ctx.invariants` 断言请求封装重建;附件后端断言字节完整性([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 持久性由插件负责。后端会缓冲同步的 `session/event` 通知。语义检查点策略会在适配器分发前刷写请求,在工具分发前刷写已记录的顶层调用,并在 `agent/post-step` 刷写完整的响应与结果批次;循环仍保留最终的轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 21883bc9ff..3e867695d6 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-attachment-local -The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Writes use a private staging directory, owner-only files, a synced temporary file, and an atomic exclusive hard-link publish; reads re-check the digest, media signature, dimensions, and logged metadata. +The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Writes use a private staging directory, owner-only files, a synced temporary file, and an atomic exclusive hard-link publish; reads re-check the digest, media signature, dimensions, and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. diff --git a/packages/attachment/attachment-local/src/image.ts b/packages/attachment/attachment-local/src/image.ts index 59ddb4ebda..4e7ae5aabb 100644 --- a/packages/attachment/attachment-local/src/image.ts +++ b/packages/attachment/attachment-local/src/image.ts @@ -11,31 +11,31 @@ export interface DetectedImage { } function ascii(data: Uint8Array, start: number, value: string): boolean { + /* v8 ignore next -- Every call site establishes the fixed header span before comparing it. */ if (data.length < start + value.length) return false for (let i = 0; i < value.length; i++) if (data[start + i] !== value.charCodeAt(i)) return false return true } function u16be(data: Uint8Array, offset: number): number { - return ((data[offset] ?? 0) << 8) | (data[offset + 1] ?? 0) + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(offset) } function u16le(data: Uint8Array, offset: number): number { - return (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8) + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(offset, true) } function u24le(data: Uint8Array, offset: number): number { - return (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8) | ((data[offset + 2] ?? 0) << 16) + const view = new DataView(data.buffer, data.byteOffset, data.byteLength) + return view.getUint8(offset) | (view.getUint8(offset + 1) << 8) | (view.getUint8(offset + 2) << 16) } function u32be(data: Uint8Array, offset: number): number { - return (((data[offset] ?? 0) * 0x1000000) + ((data[offset + 1] ?? 0) << 16) - + ((data[offset + 2] ?? 0) << 8) + (data[offset + 3] ?? 0)) >>> 0 + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset) } function u32le(data: Uint8Array, offset: number): number { - return ((data[offset] ?? 0) + ((data[offset + 1] ?? 0) << 8) - + ((data[offset + 2] ?? 0) << 16) + ((data[offset + 3] ?? 0) * 0x1000000)) >>> 0 + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset, true) } function dimensions(width: number, height: number, mediaType: ImageMediaType): DetectedImage { @@ -86,10 +86,11 @@ export function detectImage(data: Uint8Array): DetectedImage { if (declaredLength > data.length) throw new AttachmentError('WebP data is truncated.', 'INVALID_IMAGE') if (ascii(data, 12, 'VP8X')) return dimensions(u24le(data, 24) + 1, u24le(data, 27) + 1, 'image/webp') if (ascii(data, 12, 'VP8L') && data[20] === 0x2f) { - const b0 = data[21] ?? 0 - const b1 = data[22] ?? 0 - const b2 = data[23] ?? 0 - const b3 = data[24] ?? 0 + const view = new DataView(data.buffer, data.byteOffset, data.byteLength) + const b0 = view.getUint8(21) + const b1 = view.getUint8(22) + const b2 = view.getUint8(23) + const b3 = view.getUint8(24) return dimensions(1 + b0 + ((b1 & 0x3f) << 8), 1 + (b1 >> 6) + (b2 << 2) + ((b3 & 0x0f) << 10), 'image/webp') } if (ascii(data, 12, 'VP8 ') && data[23] === 0x9d && data[24] === 0x01 && data[25] === 0x2a) { diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 1c8e923867..acb06f16c9 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -67,7 +67,7 @@ export class LocalAttachmentStore extends AttachmentStore { } async readImage(ref: ImageAttachmentRef): Promise { - return readImageFile(this.root, ref, this.imageLimits) + return readImageFile(this.root, ref) } } diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 24fa3645be..22e2ca2309 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -38,15 +38,20 @@ function ensureReference(ref: ImageAttachmentRef): string { return match[1] } -function validateMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], limits: ImageAttachmentLimits): Omit { +function inspectMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType']): Omit { if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') - if (data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') const detected = detectImage(data) if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH') - if (detected.width * detected.height > limits.maxImagePixels) throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS') return { ...detected, bytes: data.byteLength } } +function validateAdmission(metadata: Omit, limits: ImageAttachmentLimits): void { + if (metadata.bytes > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') + if (metadata.width * metadata.height > limits.maxImagePixels) { + throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS') + } +} + /** * Save and verify immutable image bytes below a versioned attachment root. * @param root - absolute `DSH_HOME/attachments/v1` root. @@ -55,7 +60,8 @@ function validateMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRe * @returns durable content-addressed reference. */ export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise { - const metadata = validateMetadata(input.data, input.mediaType, limits) + const metadata = inspectMetadata(input.data, input.mediaType) + validateAdmission(metadata, limits) const sha256 = digest(input.data) const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') @@ -75,16 +81,25 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li try { await link(temporary, target) } catch (error) { + /* v8 ignore next -- Private same-filesystem directories make EEXIST the only recoverable link race. */ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error const existing = new Uint8Array(await readFile(target)) if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') } await unlink(temporary) } catch (error) { - if (handle !== undefined) await handle.close().catch(() => { /* close failure is superseded by the storage failure */ }) - await unlink(temporary).catch((cleanupError: unknown) => { - if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError - }) + /* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */ + if (handle !== undefined) await handle.close().catch( + /* v8 ignore next -- Close failure is superseded by the storage operation that entered cleanup. */ + () => {}, + ) + await unlink(temporary).catch( + /* v8 ignore next -- The callback requires a second independent staging-unlink failure. */ + (cleanupError: unknown) => { + /* v8 ignore next -- Cleanup is best-effort only for a staging file already removed by a failed operation. */ + if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError + }, + ) if (error instanceof AttachmentError) throw error throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) } @@ -100,10 +115,9 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li * Read and verify one content-addressed image. * @param root - absolute `DSH_HOME/attachments/v1` root. * @param ref - reference recorded in the session log. - * @param limits - resolved storage policy. * @returns verified bytes and reference. */ -export async function readImageFile(root: string, ref: ImageAttachmentRef, limits: ImageAttachmentLimits): Promise { +export async function readImageFile(root: string, ref: ImageAttachmentRef): Promise { const sha256 = ensureReference(ref) let data: Uint8Array try { @@ -113,7 +127,7 @@ export async function readImageFile(root: string, ref: ImageAttachmentRef, limit throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error }) } if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') - const metadata = validateMetadata(data, ref.mediaType, limits) + const metadata = inspectMetadata(data, ref.mediaType) if (metadata.bytes !== ref.bytes || metadata.width !== ref.width || metadata.height !== ref.height) { throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT') } diff --git a/packages/attachment/attachment-local/tests/image.spec.ts b/packages/attachment/attachment-local/tests/image.spec.ts new file mode 100644 index 0000000000..04cbe984fd --- /dev/null +++ b/packages/attachment/attachment-local/tests/image.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { detectImage } from '../src/image.ts' + +function bytes(text: string): number[] { + return [...Buffer.from(text, 'ascii')] +} + +function webp(chunk: string, mutate: (data: Uint8Array) => void): Uint8Array { + const data = new Uint8Array(30) + data.set(bytes('RIFF'), 0) + data.set([22, 0, 0, 0], 4) + data.set(bytes('WEBP'), 8) + data.set(bytes(chunk), 12) + mutate(data) + return data +} + +describe('raster header detection', () => { + it('detects PNG dimensions', () => { + const data = Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', + )) + expect(detectImage(data)).toEqual({ mediaType: 'image/png', width: 1, height: 1 }) + }) + + it('detects both GIF revisions and rejects zero dimensions', () => { + expect(detectImage(Uint8Array.from([...bytes('GIF87a'), 3, 0, 2, 0]))) + .toEqual({ mediaType: 'image/gif', width: 3, height: 2 }) + expect(detectImage(Uint8Array.from([...bytes('GIF89a'), 4, 0, 5, 0]))) + .toEqual({ mediaType: 'image/gif', width: 4, height: 5 }) + expect(() => detectImage(Uint8Array.from([...bytes('GIF89a'), 0, 0, 1, 0]))) + .toThrow(/positive/) + expect(() => detectImage(Uint8Array.from([...bytes('GIF89a'), 1, 0, 0, 0]))) + .toThrow(/positive/) + }) + + it('walks JPEG marker forms and reports malformed dimensions', () => { + const sof = [0xff, 0xc0, 0, 7, 8, 0, 2, 0, 3] + expect(detectImage(Uint8Array.from([0xff, 0xd8, ...sof]))) + .toEqual({ mediaType: 'image/jpeg', width: 3, height: 2 }) + expect(detectImage(Uint8Array.from([ + 0xff, 0xd8, + 0xe0, 0, 2, + 0x01, + 0xff, ...sof, + ]))).toEqual({ mediaType: 'image/jpeg', width: 3, height: 2 }) + + expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xd9, 0, 0, 0]))) + .toThrow(/missing/) + expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xff, 0xff, 0xff, 0xff]))) + .toThrow(/missing/) + expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xe0, 0, 1, 0]))) + .toThrow(/truncated/) + expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xe0, 0, 9, 0]))) + .toThrow(/truncated/) + expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xc0, 0, 6, 0, 0, 0, 0]))) + .toThrow(/dimensions are truncated/) + }) + + it('detects each WebP header and rejects truncated or unknown chunks', () => { + expect(detectImage(webp('VP8X', (data) => { + data.set([2, 0, 0], 24) + data.set([3, 0, 0], 27) + }))).toEqual({ mediaType: 'image/webp', width: 3, height: 4 }) + + expect(detectImage(webp('VP8L', (data) => { + data[20] = 0x2f + data.set([2, 0, 1, 0], 21) + }))).toEqual({ mediaType: 'image/webp', width: 3, height: 5 }) + + expect(detectImage(webp('VP8 ', (data) => { + data.set([0x9d, 0x01, 0x2a], 23) + data.set([6, 0, 7, 0], 26) + }))).toEqual({ mediaType: 'image/webp', width: 6, height: 7 }) + + const truncated = webp('VP8X', () => {}) + truncated[4] = 23 + expect(() => detectImage(truncated)).toThrow(/truncated/) + expect(() => detectImage(webp('NOPE', () => {}))).toThrow(/dimensions are missing/) + expect(() => detectImage(webp('VP8L', () => {}))).toThrow(/dimensions are missing/) + expect(() => detectImage(webp('VP8 ', () => {}))).toThrow(/dimensions are missing/) + }) + + it('rejects unrecognized bytes and near-miss signatures', () => { + expect(() => detectImage(new Uint8Array(0))).toThrow(/Unsupported/) + expect(() => detectImage(Uint8Array.from([...bytes('GIFxxa'), 1, 0, 1, 0]))) + .toThrow(/Unsupported/) + const nearWebp = webp('VP8X', () => {}) + nearWebp[8] = 0 + expect(() => detectImage(nearWebp)).toThrow(/Unsupported/) + }) +}) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts new file mode 100644 index 0000000000..b21b0d544d --- /dev/null +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -0,0 +1,39 @@ +import { Context } from 'cordis' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import LocalAttachmentStore, { + DEFAULT_MAX_IMAGE_BYTES, + DEFAULT_MAX_IMAGE_PIXELS, + DEFAULT_MAX_IMAGES_PER_MESSAGE, + DEFAULT_MAX_MESSAGE_IMAGE_BYTES, +} from '../src/index.ts' + +describe('local attachment service', () => { + it('resolves every omitted admission limit explicitly', () => { + const service = new LocalAttachmentStore(new Context(), {}) + expect(service.imageLimits).toEqual({ + maxImageBytes: DEFAULT_MAX_IMAGE_BYTES, + maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE, + maxMessageImageBytes: DEFAULT_MAX_MESSAGE_IMAGE_BYTES, + maxImagePixels: DEFAULT_MAX_IMAGE_PIXELS, + mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], + }) + }) + + it('saves and reads through the service boundary', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-service-')) + try { + const service = new LocalAttachmentStore(new Context(), { dshHome }) + const data = Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', + )) + const ref = await service.saveImage({ data, mediaType: 'image/png' }) + await expect(service.readImage(ref)).resolves.toEqual({ ref, data }) + } finally { + await rm(dshHome, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 9fff7d651e..d7ee6de31b 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -54,11 +54,21 @@ describe('local attachment store', () => { expect(new Uint8Array(await readFile(object))).toEqual(PNG) expect((await stat(object)).mode & 0o777).toBe(0o600) expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700) - await expect(readImageFile(storageRoot, first, LIMITS)).resolves.toEqual({ ref: first, data: PNG }) + await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) + }) + + it('keeps admitted history readable after deployment limits become stricter', async () => { + const storageRoot = await root() + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + + await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) }) it('rejects malformed bytes, mismatched declarations, byte limits, and decoded-pixel limits', async () => { const storageRoot = await root() + await expect(saveImageFile(storageRoot, { + data: new Uint8Array(0), mediaType: 'image/png', + }, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) await expect(saveImageFile(storageRoot, { data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', }, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) @@ -74,6 +84,10 @@ describe('local attachment store', () => { await expect(saveImageFile(storageRoot, { data: wide, mediaType: 'image/png', }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) + const unnamed = await saveImageFile(storageRoot, { + data: PNG, mediaType: 'image/png', name: '\u0000', + }, LIMITS) + expect(unnamed).not.toHaveProperty('name') }) it('fails closed when an object is missing, corrupted, or addressed by an invalid reference', async () => { @@ -83,14 +97,45 @@ describe('local attachment store', () => { const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) await chmod(object, 0o600) await writeFile(object, Uint8Array.of(1, 2, 3)) - await expect(readImageFile(storageRoot, ref, LIMITS)) + await expect(readImageFile(storageRoot, ref)) .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) - await expect(readImageFile(storageRoot, { ...ref, attachmentId: 'bad' as never }, LIMITS)) + await expect(readImageFile(storageRoot, { ...ref, attachmentId: 'bad' as never })) .rejects.toMatchObject({ code: 'INVALID_ATTACHMENT_REF' }) const missingRoot = await root() await mkdir(missingRoot, { recursive: true }) - await expect(readImageFile(missingRoot, ref, LIMITS)) + await expect(readImageFile(missingRoot, ref)) .rejects.toMatchObject({ code: 'ATTACHMENT_NOT_FOUND' }) + + const unreadableRoot = await root() + const target = join(unreadableRoot, 'objects', sha256.slice(0, 2), sha256) + await mkdir(target, { recursive: true }) + await expect(readImageFile(unreadableRoot, ref)) + .rejects.toMatchObject({ code: 'ATTACHMENT_READ_FAILED' }) + }) + + it('rejects conflicting existing objects and reference metadata mismatches', async () => { + const storageRoot = await root() + const sha256 = createHash('sha256').update(PNG).digest('hex') + const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) + await mkdir(join(storageRoot, 'objects', sha256.slice(0, 2)), { recursive: true }) + await writeFile(target, Uint8Array.of(1, 2, 3)) + await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + + await writeFile(target, PNG) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + await expect(readImageFile(storageRoot, { ...ref, width: ref.width + 1 })) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) + + it('maps unexpected publication failures to a stable storage error', async () => { + const storageRoot = await root() + const sha256 = createHash('sha256').update(PNG).digest('hex') + const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) + await mkdir(target, { recursive: true }) + + await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)) + .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' }) }) }) diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index dc9fbb85b8..3b56e01fa4 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-connection -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Each successful generation publishes its validated `host.describe` value through `onDescription` before `onConnected`; a business-error response fails the generation like a transport error. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## Model Experience diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index cf4c9c2ac3..78bf140642 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -8,6 +8,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ResponseValue, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { @@ -20,6 +21,9 @@ export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types' export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types' +/** Successful value returned by the connection-generation host handshake. */ +export type HostDescription = import('@deepseek-ai/dsh-host-apiproxy/api').ResponseValue<'host.describe'> + import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' /** diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index 6eb6491e2f..21b30e25f9 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -1,4 +1,4 @@ -import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts' +import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts' /** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; web-cordis §B.1 lists * these as the future `ctx.connection` plugin Config). All fields optional; defaults below. */ @@ -44,6 +44,8 @@ export type ConnectionState = 'connected' | 'reconnecting' export interface ConnectionSinks { onMuxEnvelope?: (envelope: RpcRequest) => void onHostEnvelope?: (envelope: RpcRequest) => void + /** Latest successful host capability snapshot for this connection generation. */ + onDescription?: (description: HostDescription) => void /** After each connection generation is established (both streams open + describe succeeded), first connect included. */ onConnected?: () => void /** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect @@ -131,13 +133,18 @@ export class ConnectionController { // subscribed baseline. The timeout guards against a carrier that never fires onOpen // (see ConnectionConfig.streamOpenTimeoutMs). const timeout = new AbortController() - await Promise.all([ + const [description] = await Promise.all([ this.api.host.describe({}), Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]), ]) timeout.abort() + const descriptionResult = description.result + if (!descriptionResult.ok) { + throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`) + } if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake') this.attempt = 0 + this.callSink(() => { this.sinks.onDescription?.(descriptionResult.value) }) this.emitState('connected') this.callSink(this.sinks.onConnected) } catch { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index fd6c5c3345..b2c5c449fc 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -19,7 +19,7 @@ export type { ToolCallView, ToolResultView, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, - IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, + HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, } from './api.ts' export { RpcId, AbstractApiClient, transportError } from './api.ts' diff --git a/packages/client/connection/tests/connection.spec.ts b/packages/client/connection/tests/connection.spec.ts index 4de4a31f25..9bb4965838 100644 --- a/packages/client/connection/tests/connection.spec.ts +++ b/packages/client/connection/tests/connection.spec.ts @@ -23,9 +23,11 @@ describe('connection lifecycle', () => { it('announces connected after describe + both streams open, then pumps frames to sinks', async () => { const api = new FakeApiClient() const muxSeen: string[] = [] + const descriptions: string[] = [] let connected = 0 const controller = new ConnectionController(api, { onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type), + onDescription: description => descriptions.push(description.version), onConnected: () => { connected++ }, }, FAST) controller.start() @@ -34,6 +36,7 @@ describe('connection lifecycle', () => { api.pushMux(subscribedFrame()) await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) }) expect(api.callsOf('host.describe')).toHaveLength(1) + expect(descriptions).toEqual(['0-fake']) } finally { controller.stop() } @@ -83,6 +86,35 @@ describe('connection lifecycle', () => { } }) + it('treats a host.describe business error as generation failure', async () => { + const api = new FakeApiClient() + let describeCalls = 0 + api.onDescribe = () => { + describeCalls += 1 + if (describeCalls === 1) { + return Promise.resolve({ + rpcId: 'bad-describe' as never, + result: { + ok: false as const, + error: { code: 'internal' as const, message: 'not ready', details: {} }, + }, + }) + } + return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 })) + } + let connected = 0 + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + controller.start() + try { + await vi.waitFor(() => { expect(describeCalls).toBe(2) }) + await vi.waitFor(() => { expect(connected).toBe(1) }) + } finally { + controller.stop() + warnSpy.mockRestore() + } + }) + it('converges stream/error frames into reconnect instead of dispatching them', async () => { const api = new FakeApiClient() const muxSeen: string[] = [] diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 6b81f4ef95..e115a381a1 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -207,6 +207,31 @@ describe('createFixtureApi', () => { }) }) + it('accounts for every base64 padding form and reports a missing fixture attachment', async () => { + const api = createFixtureApi() + const created = await api.sessions.create(req({})) + if (!created.result.ok) throw new Error('create failed') + const sessionId = created.result.value.sessionId + const prompted = await api.sessions.prompt(req({ + sessionId, + mode: 'queue' as const, + content: ['YQ==', 'YWI=', 'YWJj'].map(data => ({ + type: 'image' as const, + mediaType: 'image/png' as const, + data, + })), + })) + expect(prompted.result.ok).toBe(true) + const missing = await api.sessions.attachment(req({ + sessionId, + attachmentId: 'fixture:missing' as never, + })) + expect(missing.result).toMatchObject({ + ok: false, error: { details: { reason: 'ATTACHMENT_NOT_FOUND' } }, + }) + await api.sessions.cancel(req({ sessionId })) + }) + it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => { vi.useFakeTimers() try { diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 66eb5b22ac..b5ae63f7e7 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-runtime -Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4. +Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry, and the latest successful host capability description), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4. ## Model Experience diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 77b51a68b5..833d6dd6c9 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -148,6 +148,7 @@ export function apply(ctx: Context): void { const loop = connection.start({ onMuxEnvelope: (envelope) => { sessions.manager.handleMuxEnvelope(envelope) }, onHostEnvelope: (envelope) => { sessions.manager.handleHostEnvelope(envelope) }, + onDescription: (description) => { sessions.handleDescription(description) }, onConnected: () => { sessions.manager.handleConnected() }, }) ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop') diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index ec63b8f70d..7dbc6e8e82 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -14,7 +14,7 @@ * read-only view). */ import type { Context, Fiber } from 'cordis' -import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { HostDescription, IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' @@ -101,6 +101,7 @@ export class SessionsService { private watched: SessionId | undefined /** Removed-while-watched sessions whose teardown waits for the watch to move away. */ private readonly deferredRemovals = new Set() + private description: HostDescription | undefined /** * @param ctx - client root context (scope fibers mount under it). @@ -118,6 +119,22 @@ export class SessionsService { rootCtx.reflect.provide('sessions', this, undefined) } + /** + * Store the latest successful connection-generation host description. + * @param description - capability and deployment snapshot from `host.describe`. + */ + handleDescription(description: HostDescription): void { + this.description = description + } + + /** + * Read the latest host capability snapshot. + * @returns the last successful description, or undefined before connection. + */ + hostDescription(): HostDescription | undefined { + return this.description + } + /** * Select a session as current. Unknown ids fail loud instead of navigating * nowhere (the sole selection write path). diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 0baa2e8237..7a3e72ba6d 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -53,6 +53,8 @@ describe('runtime client apply', () => { expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new') // Mux sink and onConnected route without throwing (manager semantics own the behavior). bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never }) + bench.sinks?.onDescription?.({ version: '0', cwd: '/f', attachedSessions: 0 }) + expect(sessions?.hostDescription()).toEqual({ version: '0', cwd: '/f', attachedSessions: 0 }) bench.sinks?.onConnected?.() }) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index bca5ee9a37..83dd2e6b88 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -4,6 +4,8 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to both the conversation and details registrations, so the two session slots share one instance per session (selection written by conversation, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, view registry read face, startSession chain). +Image drafts keep only ordered runtime ids in that store. `ConversationService` owns the corresponding browser `File` and object URLs, applies the latest host capability and upload-limit snapshot before allocation, and releases draft URLs on removal or send plus historical URLs when their rendered session unmounts. Paste and drop share the same validation path; mixed clipboard text remains native textarea input. + `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). ## Model Experience diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 8087c42ebb..3159ac6802 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -94,15 +94,21 @@ export function apply(ctx: Context): void { subscribe: fn => conversation.subscribeViews(fn), version: () => conversation.viewsVersion(), }, - addImages: (files) => { - const images = conversation.createDraftImages(files) - actions.addImages(images.map(image => image.id)) + addImages: (files, current) => { + try { + const images = conversation.createDraftImages(files, current) + actions.addImages(images.map(image => image.id)) + return null + } catch (error: unknown) { + return error instanceof Error ? error.message : String(error) + } }, removeImage: (id) => { conversation.releaseDraftImage(id) actions.removeImage(id) }, draftImages: ids => conversation.draftImages(ids), + releaseSessionImages: (id) => { conversation.releaseSessionImages(id) }, send: (text, images: readonly ComposerAttachment[], mode) => { const trimmed = text.trim() if (trimmed === '' && images.length === 0) return @@ -140,6 +146,9 @@ export function apply(ctx: Context): void { slots.register({ name: 'conversation.empty', inject: (): EmptyStateInjected => ({ + createDraftImages: (files, current) => conversation.createDraftImages(files, current, true), + releaseDraftImage: (id) => { conversation.releaseDraftImage(id) }, + releaseDraftImages: (attachments) => { conversation.releaseDraftImages(attachments) }, startSession: opts => conversation.startSession(opts), }), }, EmptyState) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index cc2875add8..feb8efcdd0 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -40,15 +40,13 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted, loadImage = unavailableImage }: AssistantMarkdownProps) { const last = blocks.length - 1 - const images = blocks.filter((block): block is Extract => block.kind === 'image') return (

- {blocks.map((block, i) => { switch (block.kind) { case 'text': return case 'reasoning': return - case 'image': return null + case 'image': return // Tool-call heads render as tool rows in the chat view's grouping pass. case 'tool-call': return null default: return diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 80cb6f0b3f..a88a3b1d4d 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -14,6 +14,7 @@ import type { SelectionTarget, ViewEntry } from './views.ts' /** Browser-owned image that has not crossed the durable host boundary. */ export interface ComposerAttachment { + kind: 'image' id: string file: File previewUrl: string @@ -37,11 +38,13 @@ export interface ConversationInjected { version(): number } /** Create browser previews and append their ids through the declared store action. */ - addImages(files: readonly File[]): void + addImages(files: readonly File[], current: readonly ComposerAttachment[]): string | null /** Release one browser preview and remove its id through the declared store action. */ removeImage(id: string): void /** Resolve ordered store ids to the browser-owned draft attachments still available this runtime. */ draftImages(ids: readonly string[]): readonly ComposerAttachment[] + /** Release historical image URLs when this rendered session scope unmounts. */ + releaseSessionImages(sessionId: SessionId): void /** Send choreography: trims, clears the draft optimistically, restores it on failure. */ send(text: string, images: readonly ComposerAttachment[], mode: 'queue' | 'steer'): void /** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */ @@ -72,6 +75,12 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & /** Injected share of the no-session empty-state slot. */ export interface EmptyStateInjected { + /** Create service-owned image previews after host-capability preflight. */ + createDraftImages(files: readonly File[], current: readonly ComposerAttachment[]): readonly ComposerAttachment[] + /** Release one service-owned image preview. */ + releaseDraftImage(id: string): void + /** Release all service-owned image previews held by the empty state. */ + releaseDraftImages(attachments: readonly ComposerAttachment[]): void /** The create → navigate → first-send chain, in one service call. */ startSession(opts: { cwd?: string diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 9fc167320c..7b11e48723 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -29,6 +29,7 @@ import type { ComposerAttachment } from './contract/slots.ts' /** Opaque wrapper keeps browser `File` internals outside persisted store state. */ class BrowserDraftAttachment implements ComposerAttachment { + readonly kind = 'image' as const readonly id: string readonly previewUrl: string readonly #file: File @@ -53,10 +54,17 @@ interface ViewsState { listeners: Set<() => void> } +interface ImageUrlEntry { + readonly sessionId: SessionId + readonly generation: number + readonly pending: Promise +} + /** Scope-addressed conversation service (root singleton, provided as `conversation`). */ export class ConversationService extends Service { private readonly draftAttachments = new Map() - private readonly imageUrls = new Map>() + private readonly imageUrls = new Map() + private readonly imageGenerations = new Map() private readonly createdImageUrls = new Set() private readonly viewsState: ViewsState = { entries: new Map(), cache: null, tick: 0, listeners: new Set(), @@ -73,6 +81,7 @@ export class ConversationService extends Service { this.createdImageUrls.clear() this.draftAttachments.clear() this.imageUrls.clear() + this.imageGenerations.clear() }, 'conversation attachment URL cache') } @@ -86,6 +95,7 @@ export class ConversationService extends Service { */ async send(text: string, mode: 'queue' | 'steer', images: readonly File[] = []): Promise { const session = this.scopedSession('send') + this.validateImages(images, []) const uploaded = await Promise.all(images.map(async file => ({ type: 'image' as const, mediaType: imageMediaType(file.type), @@ -100,9 +110,16 @@ export class ConversationService extends Service { /** * Create runtime-only draft attachments and their object URLs. * @param files - browser-owned image files. + * @param current - images already present in the same composer. + * @param checkDefaultModel - whether to apply `host.describe`'s default-model capability, used only before a session exists. * @returns ordered attachment descriptors whose ids may enter the chat store. */ - createDraftImages(files: readonly File[]): readonly ComposerAttachment[] { + createDraftImages( + files: readonly File[], + current: readonly ComposerAttachment[] = [], + checkDefaultModel = false, + ): readonly ComposerAttachment[] { + this.validateImages(files, current, checkDefaultModel) return files.map((file) => { const attachment = new BrowserDraftAttachment(file) this.draftAttachments.set(attachment.id, attachment) @@ -154,7 +171,8 @@ export class ConversationService extends Service { resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { const key = `${sessionId}:${attachment.attachmentId}` const cached = this.imageUrls.get(key) - if (cached !== undefined) return cached + if (cached !== undefined) return cached.pending + const generation = this.imageGenerations.get(sessionId) ?? 0 const pending = this.requireSessions().manager.get(sessionId).readAttachment(attachment.attachmentId) .then((result) => { if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) @@ -163,17 +181,39 @@ export class ConversationService extends Service { } const bytes = Uint8Array.from(result.value.data) const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType })) + if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) { + revokePreview(url) + throw new Error('historical image scope was released before loading completed') + } this.createdImageUrls.add(url) return url }) .catch((error: unknown) => { - this.imageUrls.delete(key) + if (this.imageUrls.get(key)?.generation === generation) this.imageUrls.delete(key) throw error }) - this.imageUrls.set(key, pending) + this.imageUrls.set(key, { sessionId, generation, pending }) return pending } + /** + * Release every historical image URL owned by one rendered session. + * @param sessionId - session whose rendered image scope is ending. + */ + releaseSessionImages(sessionId: SessionId): void { + this.imageGenerations.set(sessionId, (this.imageGenerations.get(sessionId) ?? 0) + 1) + for (const [key, entry] of this.imageUrls) { + if (entry.sessionId !== sessionId) continue + this.imageUrls.delete(key) + void entry.pending.then((url) => { + if (!this.createdImageUrls.delete(url)) return + revokePreview(url) + }, () => { + // A failed or generation-invalidated load owns no cached object URL. + }) + } + } + /** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */ async cancel(): Promise { const session = this.scopedSession('cancel') @@ -284,6 +324,38 @@ export class ConversationService extends Service { if (sessions === undefined) throw new Error('conversation: sessions service unavailable') return sessions } + + /** Apply host-advertised fast-path checks before any object URL or base64 allocation. */ + private validateImages( + files: readonly File[], + current: readonly ComposerAttachment[], + checkDefaultModel = false, + ): void { + const description = this.requireSessions().hostDescription() + const modalities = description?.activeModel?.inputModalities + if (checkDefaultModel && modalities !== undefined && !modalities.includes('image')) { + throw new Error('当前模型不支持图片输入') + } + const limits = description?.imageLimits + const all = [...current.map(attachment => attachment.file), ...files] + if (limits !== undefined && all.length > limits.maxImagesPerMessage) { + throw new Error(`每条消息最多添加 ${limits.maxImagesPerMessage} 张图片`) + } + let totalBytes = 0 + for (const file of all) { + const mediaType = imageMediaType(file.type) + if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) { + throw new Error(`当前部署不支持 ${mediaType} 图片`) + } + if (limits !== undefined && file.size > limits.maxImageBytes) { + throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`) + } + totalBytes += file.size + } + if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) { + throw new Error('图片总大小超过单条消息限制') + } + } } function bumpViews(state: ViewsState): void { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index d018d93457..b94d17758d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -36,7 +36,8 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationRoot({ sessionId, useSession, useSessions, useStore, actions, - views, addImages, removeImage, draftImages, send, stop, openDetails, loadOlder, open, + views, addImages, removeImage, draftImages, releaseSessionImages, + send, stop, openDetails, loadOlder, open, }: ConversationRootProps) { useSyncExternalStore(views.subscribe, views.version) const list = views.list() @@ -63,6 +64,10 @@ export function ConversationRoot({ } }, [actions, attachments, imageIds]) + useEffect(() => () => { + releaseSessionImages(sessionId) + }, [releaseSessionImages, sessionId]) + const error: InputBarError | null = promptError === null ? null : { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` } @@ -145,7 +150,7 @@ export function ConversationRoot({ error={error} variant="composer" onDraftChange={actions.setDraft} - onAddImages={addImages} + onAddImages={files => addImages(files, attachments)} onRemoveAttachment={removeImage} onSend={(mode) => { send(draft, attachments, mode) }} onStop={stop} diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx index e837154d2a..c801369e95 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx @@ -30,7 +30,13 @@ function deriveCwds(state: SessionListState): readonly string[] { return [...seen] } -export function EmptyState({ useSessions, startSession }: EmptyStateProps) { +export function EmptyState({ + useSessions, + createDraftImages, + releaseDraftImage, + releaseDraftImages, + startSession, +}: EmptyStateProps) { const list = useSessions(s => s) const cwds = useMemo(() => deriveCwds(list), [list]) // Local viewing state: the empty state owns no session, so its draft is @@ -67,21 +73,22 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { } useEffect(() => () => { - for (const attachment of attachmentsRef.current) URL.revokeObjectURL(attachment.previewUrl) - }, []) + releaseDraftImages(attachmentsRef.current) + }, [releaseDraftImages]) - const addImages = (files: readonly File[]): void => { - setAttachments(current => [...current, ...files.map(file => ({ - id: crypto.randomUUID(), file, previewUrl: URL.createObjectURL(file), - }))]) + const addImages = (files: readonly File[]): string | null => { + try { + const added = createDraftImages(files, attachments) + setAttachments(current => [...current, ...added]) + return null + } catch (reason: unknown) { + return reason instanceof Error ? reason.message : String(reason) + } } const removeImage = (id: string): void => { - setAttachments((current) => { - const removed = current.find(item => item.id === id) - if (removed !== undefined) URL.revokeObjectURL(removed.previewUrl) - return current.filter(item => item.id !== id) - }) + releaseDraftImage(id) + setAttachments(current => current.filter(item => item.id !== id)) } const picker = ( diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index eba7c52451..6b87acfb87 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -12,12 +12,6 @@ import type { ComposerAttachment } from '../contract/slots.ts' import { ImageLightbox } from './ImageLightbox.tsx' import css from './InputBar.module.css' -const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']) - -function supportedImages(files: Iterable): File[] { - return [...files].filter(file => IMAGE_MEDIA_TYPES.has(file.type)) -} - /** Prompt failure surface (mirrors the session snapshot's promptError shape). */ export interface InputBarError { op: 'send' | 'stop' @@ -36,7 +30,7 @@ export interface InputBarProps { /** Optional leading accessory row content (the empty state mounts its cwd picker here). */ accessory?: ReactNode onDraftChange: (text: string) => void - onAddImages?: (files: readonly File[]) => void + onAddImages?: (files: readonly File[]) => string | null onRemoveAttachment?: (id: string) => void onSend: (mode: 'queue' | 'steer') => void onStop: () => void @@ -44,7 +38,7 @@ export interface InputBarProps { export function InputBar({ draft, attachments = [], running, disabled, error, variant, placeholder, accessory, - onDraftChange, onAddImages = () => {}, onRemoveAttachment = () => {}, onSend, onStop, + onDraftChange, onAddImages = () => null, onRemoveAttachment = () => {}, onSend, onStop, }: InputBarProps) { const empty = draft.trim() === '' && attachments.length === 0 const [preview, setPreview] = useState(null) @@ -90,13 +84,12 @@ export function InputBar({ const onPaste = (event: ClipboardEvent): void => { const files = [...event.clipboardData.items] - .filter(item => item.kind === 'file' && IMAGE_MEDIA_TYPES.has(item.type)) + .filter(item => item.kind === 'file') .map(item => item.getAsFile()) .filter((file): file is File => file !== null) if (files.length === 0) return - event.preventDefault() - setDropError(null) - onAddImages(files) + if (event.clipboardData.getData('text/plain') === '') event.preventDefault() + setDropError(onAddImages(files)) } const onDragEnter = (event: DragEvent): void => { @@ -127,13 +120,8 @@ export function InputBar({ setDragActive(false) if (locked) return const dropped = [...event.dataTransfer.files] - const images = supportedImages(dropped) - if (images.length === 0) { - setDropError('暂仅支持 PNG、JPEG、WebP 和 GIF 图片') - return - } - setDropError(images.length === dropped.length ? null : '已忽略不受支持的非图片文件') - onAddImages(images) + if (dropped.length === 0) return + setDropError(onAddImages(dropped)) } const closePreview = useCallback(() => { setPreview(null) }, []) @@ -187,7 +175,10 @@ export function InputBar({ type="button" className={css.remove} aria-label={`移除图片 ${attachment.file.name || ''}`} - onClick={() => { onRemoveAttachment(attachment.id) }} + onClick={() => { + setDropError(null) + onRemoveAttachment(attachment.id) + }} >×
))} @@ -204,7 +195,10 @@ export function InputBar({ disabled={locked} placeholder={placeholder ?? (disabled ? '会话不可用' : running ? '回复生成中,可停止后再输入' : '输入消息,Enter 发送,Shift+Enter 换行')} rows={2} - onChange={(e) => onDraftChange(e.target.value)} + onChange={(e) => { + setDropError(null) + onDraftChange(e.target.value) + }} onKeyDown={onKeyDown} onPaste={onPaste} onCompositionStart={onCompositionStart} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index b766f12afc..182e1436a5 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -74,6 +74,7 @@ async function bench() { manager: { get: () => sessionFake }, scope: (id: SessionId) => mint(id), cell: () => undefined, + hostDescription: () => undefined, create: vi.fn(() => Promise.resolve(ROOT)), open: vi.fn(), } @@ -200,12 +201,17 @@ describe('details and empty inject surfaces', () => { expect(details).toBe(conv) }) - it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => { + it('empty injects draft-image lifecycle and the startSession chain without a store', async () => { const b = await bench() const entry = b.entryOf('conversation.empty') expect(entry.store).toBeUndefined() const injected = (entry.inject as unknown as () => EmptyStateInjected)() - expect(Object.keys(injected)).toEqual(['startSession']) + expect(Object.keys(injected)).toEqual([ + 'createDraftImages', + 'releaseDraftImage', + 'releaseDraftImages', + 'startSession', + ]) await injected.startSession({ text: 'go', mode: 'queue' }) expect(b.sessionsFake.create).toHaveBeenCalled() expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index eb03543781..557baf6c03 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -132,8 +132,9 @@ describe('error strip and variants', () => { describe('image draft rail', () => { it('collects supported clipboard images and leaves non-image clipboard data to the browser', () => { - const onAddImages = vi.fn() - const { textarea } = setup({ draft: '', onAddImages }) + const onAddImages = vi.fn((files: readonly File[]) => + files.some(file => file.type === 'video/mp4') ? '不支持的图片格式:video/mp4' : null) + const { view, textarea } = setup({ draft: '', onAddImages }) const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' }) const prevented = fireEvent.paste(textarea, { clipboardData: { @@ -141,19 +142,25 @@ describe('image draft rail', () => { { kind: 'string', type: 'text/plain', getAsFile: () => null }, { kind: 'file', type: 'image/png', getAsFile: () => image }, ], + getData: () => '同时粘贴的文字', }, }) - expect(prevented).toBe(false) + expect(prevented).toBe(true) expect(onAddImages).toHaveBeenCalledWith([image]) + const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' }) fireEvent.paste(textarea, { - clipboardData: { items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => image }] }, + clipboardData: { + items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => video }], + getData: () => '', + }, }) - expect(onAddImages).toHaveBeenCalledTimes(1) + expect(onAddImages).toHaveBeenCalledTimes(2) + expect(view.getByText(/不支持的图片格式/)).toBeTruthy() }) it('accepts supported image drops, highlights the target, and prevents browser navigation', () => { - const onAddImages = vi.fn() + const onAddImages = vi.fn(() => null) const { view } = setup({ draft: '', onAddImages }) const card = view.container.querySelector('[class*="card"]')! const image = new File([Uint8Array.of(1, 2, 3)], 'dropped.png', { type: 'image/png' }) @@ -172,15 +179,16 @@ describe('image draft rail', () => { }) it('ignores unsupported dropped files and refuses drops while locked', () => { - const onAddImages = vi.fn() + const onAddImages = vi.fn((files: readonly File[]) => + files.some(file => file.type === 'text/plain') ? '不支持的图片格式:text/plain' : null) const { view } = setup({ draft: '', onAddImages }) const card = view.container.querySelector('[class*="card"]')! const documentFile = new File(['hello'], 'notes.txt', { type: 'text/plain' }) fireEvent.drop(card, { dataTransfer: { types: ['Files'], files: [documentFile], dropEffect: 'none' }, }) - expect(view.getByText(/暂仅支持 PNG/)).toBeTruthy() - expect(onAddImages).not.toHaveBeenCalled() + expect(view.getByText(/不支持的图片格式/)).toBeTruthy() + expect(onAddImages).toHaveBeenCalledWith([documentFile]) const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' }) const locked = setup({ draft: '', disabled: true, onAddImages }) @@ -191,12 +199,12 @@ describe('image draft rail', () => { fireEvent.dragOver(lockedCard, { dataTransfer }) expect(dataTransfer.dropEffect).toBe('none') fireEvent.drop(lockedCard, { dataTransfer }) - expect(onAddImages).not.toHaveBeenCalled() + expect(onAddImages).toHaveBeenCalledTimes(1) }) it('allows image-only send, removes a thumbnail, and opens original preview on double-click', () => { const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) - const attachment = { id: 'draft-1', file, previewUrl: 'blob:draft-1' } + const attachment = { kind: 'image' as const, id: 'draft-1', file, previewUrl: 'blob:draft-1' } const onRemoveAttachment = vi.fn() const { view, textarea, props } = setup({ draft: '', attachments: [attachment], onRemoveAttachment, diff --git a/packages/client/ui-conversation/tests/message-image.spec.tsx b/packages/client/ui-conversation/tests/message-image.spec.tsx index d78fca2069..b6746ff2ed 100644 --- a/packages/client/ui-conversation/tests/message-image.spec.tsx +++ b/packages/client/ui-conversation/tests/message-image.spec.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { MessageImage } from '../src/client/chat/MessageImage.tsx' +import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' afterEach(cleanup) @@ -41,4 +42,23 @@ describe('MessageImage', () => { await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) expect(load).toHaveBeenCalledTimes(2) }) + + it('keeps assistant images at their original position between text blocks', async () => { + const view = render( + Promise.resolve('blob:middle')} + />, + ) + const image = await view.findByAltText('middle') + const before = view.getByText('before') + const after = view.getByText('after') + expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + }) }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index b82276e555..1e5a46a441 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -7,7 +7,9 @@ * for the declared chat store (chat-store.spec.ts / selection-survival.spec.ts). */ import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -32,9 +34,17 @@ const SCOPE_TAG: symbol = (() => { interface SessionDouble { prompt: ReturnType cancel: ReturnType + readAttachment: ReturnType } -async function bench(opts?: { sessions?: boolean }) { +afterEach(() => { + vi.unstubAllGlobals() +}) + +async function bench(opts?: { + sessions?: boolean + description?: ReturnType +}) { const ctx = new Context() const sessionDoubles = new Map() const scopes = new Map() @@ -57,6 +67,7 @@ async function bench(opts?: { sessions?: boolean }) { s = { prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), + readAttachment: vi.fn(() => Promise.reject(new Error('attachment response not configured'))), } sessionDoubles.set(id, s) } @@ -66,6 +77,7 @@ async function bench(opts?: { sessions?: boolean }) { create: createMock, open: openMock, scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)), + hostDescription: () => opts?.description, } as unknown as SessionsService if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake) const fiber = ctx.plugin((pluginCtx) => { void new ConversationService(pluginCtx) }) @@ -133,6 +145,127 @@ describe('send / cancel', () => { }) }) +describe('image admission and URL lifecycle', () => { + const description: NonNullable> = { + version: '0', + cwd: '/f', + attachedSessions: 0, + activeModel: { + provider: 'anthropic', + id: 'claude-opus-4-8', + name: 'Opus', + inputModalities: ['text', 'image'], + outputModalities: ['text'], + }, + imageLimits: { + maxImageBytes: 3, + maxImagesPerMessage: 2, + maxMessageImageBytes: 4, + maxImagePixels: 100, + mediaTypes: ['image/png'], + }, + } + + it('preflights host limits before allocating previews and releases draft URLs', async () => { + const createObjectURL = vi.fn(() => 'blob:draft') + const revokeObjectURL = vi.fn() + vi.stubGlobal('URL', { createObjectURL, revokeObjectURL }) + const b = await bench({ description }) + const first = new File([Uint8Array.of(1, 2, 3)], 'first.png', { type: 'image/png' }) + const second = new File([Uint8Array.of(4, 5)], 'second.png', { type: 'image/png' }) + + const attachments = b.svc.createDraftImages([first]) + expect(attachments[0]).toMatchObject({ kind: 'image', file: first, previewUrl: 'blob:draft' }) + expect(() => b.svc.createDraftImages([second], attachments)).toThrow(/总大小/) + expect(createObjectURL).toHaveBeenCalledTimes(1) + + b.svc.releaseDraftImages(attachments) + expect(revokeObjectURL).toHaveBeenCalledWith('blob:draft') + }) + + it('rejects unsupported model capability, media type, count, and per-image bytes', async () => { + const createObjectURL = vi.fn(() => 'blob:unexpected') + vi.stubGlobal('URL', { createObjectURL, revokeObjectURL: vi.fn() }) + const textOnly = await bench({ + description: { + ...description, + activeModel: { ...description.activeModel!, inputModalities: ['text'] }, + }, + }) + const png = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) + expect(() => textOnly.svc.createDraftImages([png], [], true)).toThrow(/当前模型不支持图片/) + + const b = await bench({ description }) + const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' }) + expect(() => b.svc.createDraftImages([video])).toThrow(/不支持的图片格式/) + const large = new File([Uint8Array.of(1, 2, 3, 4)], 'large.png', { type: 'image/png' }) + expect(() => b.svc.createDraftImages([large])).toThrow(/单张大小限制/) + const existing = b.svc.createDraftImages([png, png]) + expect(() => b.svc.createDraftImages([png], existing)).toThrow(/最多添加 2 张/) + expect(createObjectURL).toHaveBeenCalledTimes(2) + }) + + it('deduplicates historical loads and revokes their URLs when the session scope ends', async () => { + const createObjectURL = vi.fn() + .mockReturnValueOnce('blob:history-1') + .mockReturnValueOnce('blob:history-2') + const revokeObjectURL = vi.fn() + vi.stubGlobal('URL', { createObjectURL, revokeObjectURL }) + const b = await bench() + const ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + } + b.sessionsFake.manager.get(sid('s1')) + const session = b.sessionDoubles.get(sid('s1'))! + session.readAttachment.mockResolvedValue({ + ok: true, + value: { attachment: ref, data: [1] }, + }) + + await expect(Promise.all([ + b.svc.resolveImage(sid('s1'), ref), + b.svc.resolveImage(sid('s1'), ref), + ])).resolves.toEqual(['blob:history-1', 'blob:history-1']) + expect(session.readAttachment).toHaveBeenCalledTimes(1) + + b.svc.releaseSessionImages(sid('s1')) + await vi.waitFor(() => { expect(revokeObjectURL).toHaveBeenCalledWith('blob:history-1') }) + await expect(b.svc.resolveImage(sid('s1'), ref)).resolves.toBe('blob:history-2') + expect(session.readAttachment).toHaveBeenCalledTimes(2) + }) + + it('revokes a historical URL whose load completes after its session scope was released', async () => { + const createObjectURL = vi.fn(() => 'blob:late') + const revokeObjectURL = vi.fn() + vi.stubGlobal('URL', { createObjectURL, revokeObjectURL }) + const b = await bench() + const ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + } + const response = Promise.withResolvers<{ + ok: true + value: { attachment: ImageAttachmentRef; data: number[] } + }>() + b.sessionsFake.manager.get(sid('s1')) + b.sessionDoubles.get(sid('s1'))!.readAttachment.mockReturnValue(response.promise) + + const pending = b.svc.resolveImage(sid('s1'), ref) + b.svc.releaseSessionImages(sid('s1')) + response.resolve({ ok: true, value: { attachment: ref, data: [1] } }) + + await expect(pending).rejects.toThrow(/scope was released/) + expect(revokeObjectURL).toHaveBeenCalledWith('blob:late') + }) +}) + describe('startSession chain', () => { it('creates, navigates through sessions.open, then sends through the new scope', async () => { const b = await bench() diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index 1d4b76091a..fbaed548c2 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -71,9 +71,10 @@ describe('ConversationRoot branches', () => { useStore={hookOf(chat)} actions={chat.actions} views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }} - addImages={vi.fn()} + addImages={vi.fn(() => null)} removeImage={vi.fn()} draftImages={() => []} + releaseSessionImages={vi.fn()} send={vi.fn()} stop={vi.fn()} openDetails={vi.fn()} @@ -132,9 +133,10 @@ describe('ConversationRoot branches', () => { useStore={hookOf(chat)} actions={chat.actions} views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }} - addImages={vi.fn()} + addImages={vi.fn(() => null)} removeImage={vi.fn()} draftImages={() => []} + releaseSessionImages={vi.fn()} send={vi.fn()} stop={vi.fn()} openDetails={vi.fn()} @@ -240,7 +242,13 @@ describe('EmptyState branches', () => { it('keeps the draft and surfaces a local error strip when startSession rejects', async () => { const startSession = vi.fn(() => Promise.reject(new Error('create down'))) const view = render( - , + []} + releaseDraftImage={() => {}} + releaseDraftImages={() => {}} + startSession={startSession} + />, ) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'first task' } }) @@ -252,7 +260,13 @@ describe('EmptyState branches', () => { it('non-Error rejection reasons stringify into the error strip', async () => { const startSession = vi.fn(() => Promise.reject('plain-string')) const view = render( - , + []} + releaseDraftImage={() => {}} + releaseDraftImages={() => {}} + startSession={startSession} + />, ) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'go' } }) @@ -268,6 +282,9 @@ describe('EmptyState branches', () => { { id: 'a', title: 'a', cwd: '/proj' }, { id: 'b', title: 'b' }, // no cwd: filtered from the option set ])} + createDraftImages={() => []} + releaseDraftImage={() => {}} + releaseDraftImages={() => {}} startSession={startSession} />, ) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index d7e490c7ee..a49265c225 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -68,7 +68,15 @@ describe('EmptyState', () => { ]) let reject!: (e: Error) => void const startSession = vi.fn(() => new Promise((_res, rej) => { reject = rej })) - render() + render( + []} + releaseDraftImage={() => {}} + releaseDraftImages={() => {}} + startSession={startSession} + />, + ) const select = screen.getByRole('combobox', { name: '项目目录' }) expect([...(select as HTMLSelectElement).options].map(o => o.value)) @@ -87,12 +95,59 @@ describe('EmptyState', () => { it('new-directory option swaps the select for a free-form input', () => { const { useSessions } = fakeSessions([]) - render( Promise.resolve()} />) + render( + []} + releaseDraftImage={() => {}} + releaseDraftImages={() => {}} + startSession={() => Promise.resolve()} + />, + ) fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } }) const custom = screen.getByPlaceholderText(/目录路径/) fireEvent.change(custom, { target: { value: '/tmp/fresh' } }) expect((custom as HTMLInputElement).value).toBe('/tmp/fresh') }) + + it('routes empty-state draft image creation and release through the injected lifecycle', () => { + const { useSessions } = fakeSessions([]) + const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) + const attachment = { + kind: 'image' as const, + id: 'draft-1', + file, + previewUrl: 'blob:draft-1', + } + const createDraftImages = vi.fn() + .mockReturnValueOnce([attachment]) + .mockImplementationOnce(() => { throw new Error('图片过大') }) + const releaseDraftImage = vi.fn() + const releaseDraftImages = vi.fn() + const view = render( + Promise.resolve()} + />, + ) + const textarea = view.container.querySelector('textarea')! + const clipboardData = { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => file }], + getData: () => '', + } + fireEvent.paste(textarea, { clipboardData }) + expect(createDraftImages).toHaveBeenCalledWith([file], []) + fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' })) + expect(releaseDraftImage).toHaveBeenCalledWith('draft-1') + + fireEvent.paste(textarea, { clipboardData }) + expect(view.getByText('图片过大')).toBeTruthy() + view.unmount() + expect(releaseDraftImages).toHaveBeenCalledWith([]) + }) }) describe('ConversationRoot', () => { @@ -121,9 +176,10 @@ describe('ConversationRoot', () => { subscribe: () => () => {}, version: () => 1, }} - addImages={vi.fn()} + addImages={vi.fn(() => null)} removeImage={vi.fn()} draftImages={() => []} + releaseSessionImages={vi.fn()} send={send} stop={stop} openDetails={openDetails} diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 6b35741846..b69a6761aa 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -99,6 +99,7 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] = addImages={vi.fn()} removeImage={vi.fn()} draftImages={() => []} + releaseSessionImages={vi.fn()} send={vi.fn()} stop={vi.fn()} openDetails={vi.fn()} diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 7279cc029d..4e1dc4cedf 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -13,7 +13,7 @@ This backend owns the compaction policy: - **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune. - **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. -- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. +- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim, including image references, and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. The selected adapter must resolve or explicitly reject those images. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call; image output fails with `UNSUPPORTED_CONTENT` rather than disappearing. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. - **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes. - **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. @@ -100,7 +100,7 @@ Replacing rather than append-only. Each checkpoint invalidates reuse from the fi #### What the model sees -The summarization model receives the conversation replayed verbatim — the same system prompt, tool schemas, and messages the last routed request sent for the shadowed region — followed by one final user message: the compaction instruction below. The conversation model never sees this private request or its reasoning; only returned text is stored. +The summarization model receives the conversation replayed verbatim — the same system prompt, tool schemas, and messages, including image references, that the last routed request sent for the shadowed region — followed by one final user message: the compaction instruction below. The conversation model never sees this private request or its reasoning; only returned text is stored. ##### Compaction instruction (final user message) diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index ce4f28f8b3..2c107f047c 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -5,7 +5,7 @@ */ import type { Context } from 'cordis' -import { BlockAssembler } from '@deepseek-ai/dsh-llm' +import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, GenerateOptions, Message, ToolSchema } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -145,7 +145,7 @@ export async function summarizeWithLlm( const error = finishError(assembler.finish) if (error !== undefined) throw error - const summary = textOnly(assembler.message().content) + const summary = summaryText(assembler.message().content) if (!summary.some(block => block.text.trim().length > 0)) { throw new Error('summarization produced no text summary content') } @@ -189,9 +189,18 @@ function finishError(finish: FinishReason): Error | undefined { } } -/** Keep only text blocks before synthesizing a user message. */ -function textOnly( +/** Reject visual output and keep only text before synthesizing a user message. */ +function summaryText( blocks: readonly ContentBlock[], ): Array> { + if (containsImage(blocks)) { + throw new LlmError('compaction summary cannot contain image output', 'UNSUPPORTED_CONTENT') + } return blocks.filter((block): block is Extract => block.type === 'text') } + +/** Detect images recursively so no structured result can hide a silent visual drop. */ +function containsImage(blocks: readonly ContentBlock[]): boolean { + return blocks.some(block => block.type === 'image' + || (block.type === 'tool-result' && containsImage(block.content))) +} diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 3ea658b64d..1b6ff4e7de 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' @@ -1103,7 +1104,22 @@ describe('default one-shot summarizer', () => { it('replays the conversation prefix and appends the instruction as the final message', async () => { const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }]) const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }] - const prefix: Message = { role: 'user', content: [{ type: 'text', text: 'earlier turn' }] } + const prefix: Message = { + role: 'user', + content: [ + { type: 'text', text: 'earlier turn' }, + { + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, + ], + } await compact.runSummarize({ system: 'REPLAYED SYSTEM', tools, @@ -1256,6 +1272,43 @@ describe('default one-shot summarizer', () => { await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL))) .rejects.toThrow(/no text summary content/) }) + + it('rejects image summary output instead of silently dropping it', async () => { + const { compact } = await summarizerHarness([ + { + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, + { type: 'text', text: 'partial summary' }, + ]) + await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL))) + .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + }) + + it('rejects image summary output nested in a tool result', async () => { + const { compact } = await summarizerHarness([{ + type: 'tool-result', + toolCallId: CallId('summary-tool'), + content: [{ + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }], + }]) + await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL))) + .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + }) }) describe('automatic listener and loader composition', () => { diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 8681cb3b3e..3a1803e5de 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -387,11 +387,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { if (content.some(part => part.type === 'image')) { - const activeModel = (await ctx.llm.listModels(defaults.provider)).find(model => model.id === defaults.model) + const routed = agent.session.requestHeader()?.config + const provider = routed?.provider ?? agent.options.provider ?? defaults.provider + const model = routed?.model ?? agent.options.model ?? defaults.model + const activeModel = (await ctx.llm.listModels(provider)).find(candidate => candidate.id === model) if (activeModel?.inputModalities !== undefined && !activeModel.inputModalities.includes('image')) { return err(request, { code: 'attachment-error', - message: `Model "${defaults.model}" does not support image input.`, + message: `Model "${model}" does not support image input.`, details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, }) } diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index c48931e579..91a19b4397 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -18,13 +18,14 @@ class ScriptedAdapter extends LlmAdapter { constructor( private script: (StreamChunk[] | 'hang')[], private readonly inputModalities: readonly ModelModality[] = ['text', 'image'], + private readonly model = 'test-model', ) { super() } override listModels(provider: string): Promise { return Promise.resolve([{ - provider, id: 'test-model', name: 'test-model', + provider, id: this.model, name: this.model, inputModalities: this.inputModalities, outputModalities: ['text'], }]) } @@ -112,11 +113,38 @@ describe('bootHost / startHost', () => { const response = await running.handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', body })) const parsed = await response.json() as { result: { ok: boolean; value: { provider: string } } } expect(parsed.result.value.provider).toBe('scripted') + const attachmentBody = JSON.stringify({ + type: 'client-request', + rpcId: 'r-attachment', + method: 'session.attachment', + payload: { sessionId: 'session-missing', attachmentId: 'sha256:missing' }, + }) + const attachmentResponse = await running.handler.fetch(new Request('http://x/api/session.attachment', { + method: 'POST', + body: attachmentBody, + })) + expect((await attachmentResponse.json() as { result: { ok: boolean } }).result.ok).toBe(false) const first = running.dispose() expect(running.dispose()).toBe(first) await first host = undefined }) + + it('mounts configured pi-ai providers while accepting an explicit empty list', async () => { + const empty = await bootHost({ + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-pi-empty-')), + piAiProviders: [], + }) + expect(empty.ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) + await empty.dispose() + + const configured = await bootHost({ + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-pi-')), + piAiProviders: [{ provider: 'openai' }], + }) + expect(configured.ctx.llm.listProviders()).toContainEqual({ id: 'openai', name: 'openai' }) + await configured.dispose() + }) }) describe('host.describe', () => { @@ -125,6 +153,18 @@ describe('host.describe', () => { const value = expectOk(await api.host.describe(request({}))) expect(value).toMatchObject({ version: '0.0.1', cwd: process.cwd(), provider: 'scripted', model: 'test-model', attachedSessions: 0 }) }) + + it('omits activeModel when the configured model is absent from the provider catalog', async () => { + host = await startHost({ + boot: { + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-describe-missing-model-')), + provider: 'scripted', + model: 'missing-model', + }, + }) + host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text'], 'other-model')) + expect(expectOk(await host.api.host.describe(request({})))).not.toHaveProperty('activeModel') + }) }) describe('sessions.create / list', () => { @@ -239,6 +279,125 @@ describe('sessions.prompt / cancel', () => { expect(denied.result).toMatchObject({ ok: false, error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }, }) + + const { sessionId: nestedSession } = expectOk(await host.api.sessions.create(request({}))) + const nestedAgent = host.ctx.agents.get(nestedSession) as Agent + nestedAgent.session.append('context/message', { + content: [ + null, + [], + { + type: 'tool-result', + toolCallId: 'nested-text' as never, + content: [{ type: 'text', text: 'no image here' }], + }, + { + type: 'tool-result', + toolCallId: 'nested-image' as never, + content: [{ type: 'image', attachment: image.attachment }], + }, + ] as never, + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + expectOk(await host.api.sessions.attachment(request({ + sessionId: nestedSession, + attachmentId: image.attachment.attachmentId, + }))) + + const { sessionId: streamedSession } = expectOk(await host.api.sessions.create(request({}))) + const streamedAgent = host.ctx.agents.get(streamedSession) as Agent + streamedAgent.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: image.attachment } }, + }) + expectOk(await host.api.sessions.attachment(request({ + sessionId: streamedSession, + attachmentId: image.attachment.attachmentId, + }))) + + const missingRef = { + ...image.attachment, + attachmentId: `sha256:${'b'.repeat(64)}` as never, + } + streamedAgent.session.append('context/message', { + content: [{ type: 'image', attachment: missingRef }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + const missing = await host.api.sessions.attachment(request({ + sessionId: streamedSession, + attachmentId: missingRef.attachmentId, + })) + expect(missing.result).toMatchObject({ + ok: false, error: { details: { reason: 'ATTACHMENT_NOT_FOUND' } }, + }) + + const read = vi.spyOn(host.ctx.attachments, 'readImage').mockRejectedValueOnce(new Error('read failed')) + const internal = await host.api.sessions.attachment(request({ + sessionId: nestedSession, + attachmentId: image.attachment.attachmentId, + })) + expect(internal.result).toMatchObject({ ok: false, error: { code: 'internal' } }) + read.mockRestore() + + const ghost = await host.api.sessions.attachment(request({ + sessionId: 'session-ghost' as SessionId, + attachmentId: image.attachment.attachmentId, + })) + expect(ghost.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + }) + + it('rejects non-canonical, excessive-count, and excessive-byte image prompts', async () => { + const running = await boot() + const { sessionId } = expectOk(await running.api.sessions.create(request({}))) + for (const data of ['', 'AB==']) { + const invalid = await running.api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [{ type: 'image' as const, mediaType: 'image/png' as const, data }], + })) + expect(invalid.result).toMatchObject({ + ok: false, error: { details: { reason: 'INVALID_IMAGE_BASE64' } }, + }) + } + + const attachmentService = running.ctx.attachments as unknown as { + imageLimits: typeof running.ctx.attachments.imageLimits + } + attachmentService.imageLimits = { + ...running.ctx.attachments.imageLimits, + maxImagesPerMessage: 1, + } + const tooMany = await running.api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: Array.from({ length: 2 }, () => ({ + type: 'image' as const, + mediaType: 'image/png' as const, + data: PNG_BASE64, + })), + })) + expect(tooMany.result).toMatchObject({ + ok: false, error: { details: { reason: 'TOO_MANY_IMAGES' } }, + }) + + attachmentService.imageLimits = { + ...running.ctx.attachments.imageLimits, + maxImagesPerMessage: 10, + maxMessageImageBytes: 100, + } + const excessiveBytes = await running.api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: Array.from({ length: 2 }, () => ({ + type: 'image' as const, + mediaType: 'image/png' as const, + data: PNG_BASE64, + })), + })) + expect(excessiveBytes.result).toMatchObject({ + ok: false, error: { details: { reason: 'IMAGES_TOO_LARGE' } }, + }) }) it('rejects images for an explicitly text-only model without creating a session event', async () => { @@ -260,6 +419,57 @@ describe('sessions.prompt / cancel', () => { expect(existsSync(join(dshHome, 'attachments'))).toBe(false) }) + it('preflights the session route instead of the host default model', async () => { + const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-routed-session-')) + const dshHome = mkdtempSync(join(tmpdir(), 'dsh-routed-home-')) + host = await startHost({ + boot: { persistenceRoot, dshHome, provider: 'scripted', model: 'test-model' }, + }) + host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text'])) + host.ctx.llm.registerAdapter( + ['visual'], + new ScriptedAdapter([textResponse('seen')], ['text', 'image'], 'visual-model'), + ) + const { sessionId } = expectOk(await host.api.sessions.create(request({}))) + const agent = host.ctx.agents.get(sessionId) as Agent + agent.options.provider = 'visual' + agent.options.model = 'visual-model' + const idle = waitForIdle(host.ctx, agent) + + expectOk(await host.api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64 }], + }))) + await idle + + expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true) + expect(existsSync(join(dshHome, 'attachments'))).toBe(true) + }) + + it('falls back to host routing when a session has no routed or agent model options', async () => { + host = await startHost({ + boot: { + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-route-default-')), + provider: 'scripted', + model: 'test-model', + }, + }) + host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text'])) + const { sessionId } = expectOk(await host.api.sessions.create(request({}))) + const agent = host.ctx.agents.get(sessionId) as Agent + agent.options.provider = undefined as never + agent.options.model = undefined as never + const response = await host.api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64 }], + })) + expect(response.result).toMatchObject({ + ok: false, error: { details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } }, + }) + }) + it('cancels an attached agent and rejects an unattached one', async () => { const running = await boot(['hang']) const { api, ctx } = running diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 9e59699bd6..ece3a52d48 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,7 +2,7 @@ Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. -The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; port `0` requests an OS-assigned port and the running handle reports the assigned value. `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. +The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply the bind `host`, `port`, and positive `maxRequestBodyBytes`; port `0` requests an OS-assigned port and the running handle reports the assigned value. The API bridge returns 413 before buffering a declared oversized body and keeps chunked-body buffering within the same cap. `dsh web` derives its default cap from the configured aggregate image limit plus base64/envelope expansion and accepts `--max-request-body-bytes` as an explicit override. It defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own. diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 074bfe395c..0b0cafb404 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -33,6 +33,8 @@ export interface WebServerOptions { distIndex: string /** Fetch-shaped API carrier; /api/*-prefixed requests are bridged to it. */ apiHandler: { fetch: typeof fetch } + /** Maximum buffered bytes accepted for one `/api/*` request body. */ + maxRequestBodyBytes: number /** * Web plugin table. When present, every index.html response carries a * `window.__DSH_BOOT__` manifest script and `/plugins//client.js` serves @@ -66,7 +68,10 @@ export interface RunningWebServer { * @returns the running server handle once listening. */ export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise { - const { host, port, distIndex, apiHandler, webPlugins } = options + const { host, port, distIndex, apiHandler, maxRequestBodyBytes, webPlugins } = options + if (!Number.isInteger(maxRequestBodyBytes) || maxRequestBodyBytes < 1) { + throw new RangeError('host webserver: maxRequestBodyBytes must be a positive integer') + } const distRoot = dirname(distIndex) const renderIndex = webPlugins === undefined ? undefined : async (): Promise => { const html = await readFile(distIndex, 'utf8') @@ -78,7 +83,7 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error) requests; the field is only optional on the client-side IncomingMessage type */ const rawPath = new URL(req.url ?? '/', 'http://x').pathname if (rawPath.startsWith('/api/')) { - await bridge(req, res, apiHandler) + await bridge(req, res, apiHandler, maxRequestBodyBytes) return } if (req.method !== 'GET' && req.method !== 'HEAD') { @@ -163,8 +168,13 @@ async function servePluginBundle( } } -/** Bridge one node:http request to the WHATWG fetch handler (client close aborts; SSE bodies stream out chunk by chunk). */ -async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise { +/** Bridge one bounded node:http request to the WHATWG fetch handler. */ +async function bridge( + req: IncomingMessage, + res: ServerResponse, + apiHandler: { fetch: typeof fetch }, + maxRequestBodyBytes: number, +): Promise { const abort = new AbortController() // Client-disconnect detection MUST hang off the response, not the request: // since Node 16, IncomingMessage 'close' fires as soon as the request body is @@ -174,8 +184,31 @@ async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { f res.on('close', () => { if (!res.writableEnded) abort.abort() }) + const declaredLength = req.headers['content-length'] + if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) { + res.writeHead(413) + res.end() + req.resume() + return + } const chunks: Buffer[] = [] - for await (const chunk of req) chunks.push(chunk as Buffer) + let received = 0 + let oversized = false + for await (const chunk of req) { + const buffer = chunk as Buffer + received += buffer.byteLength + if (received > maxRequestBodyBytes) { + oversized = true + chunks.length = 0 + continue + } + if (!oversized) chunks.push(buffer) + } + if (oversized) { + res.writeHead(413) + res.end() + return + } /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server requests; the fields are only optional on the client-side IncomingMessage type */ const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), { diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 0b9d1a8978..49d9b7a233 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -1,10 +1,13 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { request as httpRequest } from 'node:http' import { createServer as createNetServer, Server as NetServer, type AddressInfo } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { startWebServer, type RunningWebServer } from '../src/index.ts' +const MAX_REQUEST_BODY_BYTES = 64 * 1024 + /** Reserve a loopback port for tests that need to address a second server. */ function freePort(): Promise { return new Promise((resolve, reject) => { @@ -104,17 +107,35 @@ afterEach(async () => { server = undefined }) -async function boot(onError: (err: Error) => void = () => undefined): Promise { +async function boot( + onError: (err: Error) => void = () => undefined, + maxRequestBodyBytes = MAX_REQUEST_BODY_BYTES, +): Promise { const { distIndex } = makeDist() const port = await freePort() - server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, onError) + server = await startWebServer({ + host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes, + }, onError) return `http://127.0.0.1:${String(server.port)}` } describe('startWebServer', () => { + it('rejects an invalid request-body cap before listening', () => { + const { distIndex } = makeDist() + expect(() => startWebServer({ + host: '127.0.0.1', + port: 0, + distIndex, + apiHandler: echoingApi, + maxRequestBodyBytes: 0, + }, () => undefined)).toThrow(/positive integer/) + }) + it('reports the listening port and closes idempotently', async () => { const { distIndex } = makeDist() - server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined) + server = await startWebServer({ + host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, + }, () => undefined) expect(server.port).toBeGreaterThan(0) const first = server.close() const second = server.close() @@ -136,7 +157,9 @@ describe('startWebServer', () => { }) const address = vi.spyOn(NetServer.prototype, 'address').mockReturnValue({ address: host, family: 'IPv4', port }) try { - const inertServer = await startWebServer({ host, port, distIndex, apiHandler: echoingApi }, () => undefined) + const inertServer = await startWebServer({ + host, port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, + }, () => undefined) expect(listen).toHaveBeenCalledWith(port, host, expect.any(Function)) await inertServer.close() } finally { @@ -148,8 +171,12 @@ describe('startWebServer', () => { it('rejects when the port is already taken', async () => { const { distIndex } = makeDist() const port = await freePort() - server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined) - await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined)) + server = await startWebServer({ + host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, + }, () => undefined) + await expect(startWebServer({ + host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, + }, () => undefined)) .rejects.toMatchObject({ code: 'EADDRINUSE' }) }) }) @@ -207,7 +234,10 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti } const port = await freePort() server = await startWebServer( - { host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined, + { + host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, + maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, webPlugins, + }, () => undefined, ) return `http://127.0.0.1:${String(server.port)}` } @@ -245,7 +275,10 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti } const port = await freePort() server = await startWebServer( - { host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined, + { + host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, + maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, webPlugins, + }, () => undefined, ) const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/@deepseek-ai/dsh-client-connection/client.js`) expect(res.status).toBe(404) @@ -305,6 +338,35 @@ describe('/api bridge', () => { expect(await response.json()).toEqual({ method: 'POST', body: '{"n":1}', header: 'p1' }) }) + it('returns 413 before buffering a declared oversized body', async () => { + const base = await boot() + const response = await fetch(`${base}/api/echo`, { + method: 'POST', + body: 'x'.repeat(MAX_REQUEST_BODY_BYTES + 1), + }) + expect(response.status).toBe(413) + }) + + it('bounds chunked request buffering when no content length is declared', async () => { + const base = await boot(() => undefined, 8) + const target = new URL(`${base}/api/echo`) + const status = await new Promise((resolve, reject) => { + const request = httpRequest({ + hostname: target.hostname, + port: target.port, + path: target.pathname, + method: 'POST', + }, (response) => { + response.resume() + response.on('end', () => { resolve(response.statusCode) }) + }) + request.on('error', reject) + request.write('12345') + request.end('67890') + }) + expect(status).toBe(413) + }) + it('relays a bodyless response', async () => { const base = await boot() const response = await fetch(`${base}/api/empty`, { method: 'POST' }) diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 8a6736f112..4e17552cde 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -34,6 +34,8 @@ Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reason The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. +Image requests resolve the optional `ctx.attachments` service when the request is dispatched, so Cordis plugin load order does not freeze attachment availability. A visual request still fails explicitly with `UNSUPPORTED_CONTENT` when the service or the selected model's image capability is absent. + ## Provider/model routing and replay The selected pi-ai catalog descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name. diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 64cdb1699f..dec252264e 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -25,8 +25,8 @@ import { toStreamChunks } from './stream.ts' export interface PiAiAdapterOptions { /** Validated provider profiles this adapter instance owns. */ profiles: readonly PiAiProviderProfile[] - /** Durable image resolver used only when a request contains image references. */ - attachments?: AttachmentStore + /** Resolve durable image storage at request time so plugin load order does not become capability state. */ + resolveAttachments?: () => AttachmentStore | undefined } /** @@ -72,12 +72,12 @@ function requestHeaders(headers: Readonly> | undefined): */ export class PiAiAdapter extends LlmAdapter { private readonly profiles: ReadonlyMap - private readonly attachments: AttachmentStore | undefined + private readonly resolveAttachments: () => AttachmentStore | undefined constructor(options: PiAiAdapterOptions) { super() this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile])) - this.attachments = options.attachments + this.resolveAttachments = options.resolveAttachments ?? (() => undefined) } override listModels(provider: string): Promise { @@ -136,12 +136,13 @@ export class PiAiAdapter extends LlmAdapter { if (containsImage && !model.input.includes('image')) { throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT') } - if (containsImage && this.attachments === undefined) { + const attachments = containsImage ? this.resolveAttachments() : undefined + if (containsImage && attachments === undefined) { throw new LlmError('pi-ai image input requires the durable attachment service', 'UNSUPPORTED_CONTENT') } - const context = this.attachments === undefined + const context = attachments === undefined ? toPiContext(options) - : await toPiContext(options, this.attachments) + : await toPiContext(options, attachments) const events = streamSimple(model, context, { ...profileOptions(profile), ...options.temperature === undefined ? {} : { temperature: options.temperature }, diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 2b0d842e47..27fcd40b57 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -36,10 +36,9 @@ export const inject = ['llm'] /** Register one generic pi-ai adapter for all configured provider routes. */ export function apply(ctx: Context, config: Config): void { const profiles = resolveProfiles(config.providers) - const attachments = ctx.get('attachments') const adapter = new PiAiAdapter({ profiles, - ...(attachments === undefined ? {} : { attachments }), + resolveAttachments: () => ctx.get('attachments'), }) ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter) } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 63e30c0ed5..de12d81e5e 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -2,6 +2,13 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' @@ -88,6 +95,14 @@ const textEvents = [ '[DONE]', ] +const IMAGE_REF: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, +} + async function harness(baseURL: string, overrides: Record = {}): Promise { const ctx = new Context() await ctx.plugin(LlmService) @@ -189,6 +204,55 @@ describe('PiAiAdapter provider routing', () => { expect(server.paths).toEqual(['/v1/responses']) }) + it('resolves an attachment service mounted after the adapter when dispatching an image', async () => { + const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }]) + const attachmentId = AttachmentId(`sha256:${'a'.repeat(64)}`) + const ref: ImageAttachmentRef = { + attachmentId, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + } + const readImage = vi.fn((_ref: ImageAttachmentRef): Promise => + Promise.resolve({ ref, data: Uint8Array.of(1) })) + + class LateAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: 1, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1, + maxImagePixels: 1, + mediaTypes: ['image/png'], + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('not used')) + } + + readImage(value: ImageAttachmentRef): Promise { + return readImage(value) + } + } + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], + }) + await ctx.plugin(LateAttachmentStore) + + const result = await assemble(ctx, { + provider: 'openai', + model: 'gpt-4.1', + messages: [{ role: 'user', content: [{ type: 'image', attachment: ref }] }], + }) + + expect(result.finish.kind).toBe('error') + expect(readImage).toHaveBeenCalledWith(ref) + expect(server.paths).toEqual(['/v1/responses']) + }) + it('forces one wire request for an SDK-retryable provider failure', async () => { const server = await mockServer([ { @@ -387,6 +451,38 @@ describe('provider profile lifecycle', () => { expect(new LlmError('x', 'X')).toBeInstanceOf(Error) }) + it('rejects unsupported or unresolved image input before provider I/O', async () => { + const adapter = new PiAiAdapter({ + profiles: [{ provider: 'openai' }, { provider: 'deepseek' }], + }) + const drain = async (options: Parameters[0]): Promise => { + for await (const _chunk of adapter.stream(options)) { /* drain */ } + } + + await expect(drain({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + messages: [{ role: 'user', content: [{ type: 'image', attachment: IMAGE_REF }] }], + })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + await expect(drain({ + provider: 'openai', + model: 'gpt-4.1', + messages: [{ role: 'user', content: [{ type: 'image', attachment: IMAGE_REF }] }], + })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + await expect(drain({ + provider: 'openai', + model: 'gpt-4.1', + messages: [{ + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: 'call-image' as never, + content: [{ type: 'image', attachment: IMAGE_REF }], + }], + }], + })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + }) + it('validates direct-constructor profiles at the embedding boundary', () => { expect(() => new PiAiAdapter({ profiles: [{ provider: 'openai', streamIdleTimeoutMs: 0 }], diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts new file mode 100644 index 0000000000..f3e348d98a --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions } from '@deepseek-ai/dsh-llm' +import { toPiContext } from '../src/context.ts' +import { toPiAssistant } from '../src/replay.ts' + +const ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, +} + +const attachments = { + readImage: vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1) })), +} as unknown as AttachmentStore + +function request(messages: GenerateOptions['messages']): GenerateOptions { + return { + provider: 'openai', + model: 'gpt-4.1', + system: 'system prompt', + tools: [{ name: 'lookup', description: 'look up', parameters: { type: 'object' } }], + messages, + } +} + +describe('pi-ai request context conversion', () => { + it('omits absent and empty request-level optional fields', () => { + const base = { provider: 'openai', model: 'gpt-4.1', messages: [] } + expect(toPiContext(base)).toEqual({ messages: [] }) + expect(toPiContext({ ...base, tools: [] })).toEqual({ messages: [] }) + }) + + it('converts complete text-only history and rejects nested images without storage', () => { + const callId = CallId('call-1') + expect(toPiContext(request([ + { role: 'system', content: [{ type: 'text', text: 'history system' }] }, + { + role: 'assistant', + content: [{ type: 'tool-call', id: callId, name: 'lookup', arguments: '{}' }], + }, + { + role: 'user', + content: [ + { type: 'text', text: 'after tool' }, + { + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text: '' }], + }, + ], + }, + ]))).toMatchObject({ + systemPrompt: 'system prompt', + tools: [{ name: 'lookup' }], + messages: [ + { role: 'user', content: 'history system' }, + { role: 'assistant' }, + { role: 'user', content: 'after tool' }, + { + role: 'toolResult', + toolCallId: 'call-1', + toolName: 'lookup', + content: [{ type: 'text', text: '(no output)' }], + isError: false, + }, + ], + }) + + expect(() => toPiContext(request([{ + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'image', attachment: ref }], + }], + }]))).toThrow(/durable attachment service/) + }) + + it('resolves user and tool-result images while preserving explicit fallbacks', async () => { + const callId = CallId('missing-call') + const knownCallId = CallId('known-call') + const context = await toPiContext(request([ + { role: 'user', content: [{ type: 'text', text: '' }] }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'calling' }, + { type: 'tool-call', id: knownCallId, name: 'lookup', arguments: '{}' }, + ], + }, + { + role: 'user', + content: [ + { type: 'image', attachment: ref }, + { type: 'text', text: 'caption' }, + { type: 'reasoning', text: 'ignored' }, + ], + }, + { + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: knownCallId, + content: [{ type: 'text', text: '' }], + }], + }, + { + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: callId, + isError: true, + content: [ + { type: 'tool-result', toolCallId: callId, content: [] }, + { type: 'image', attachment: ref }, + ], + }], + }, + ]), attachments) + + expect(context.messages).toEqual([ + { role: 'user', content: '', timestamp: 0 }, + expect.objectContaining({ role: 'assistant' }), + { + role: 'user', + content: [ + { type: 'image', data: 'AQ==', mimeType: 'image/png' }, + { type: 'text', text: 'caption' }, + ], + timestamp: 0, + }, + { + role: 'toolResult', + toolCallId: 'known-call', + toolName: 'lookup', + content: [{ type: 'text', text: '(no output)' }], + isError: false, + timestamp: 0, + }, + { + role: 'toolResult', + toolCallId: 'missing-call', + toolName: 'unknown', + content: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }], + isError: true, + timestamp: 0, + }, + ]) + }) + + it('keeps empty text-only users while separating result-only messages', () => { + const callId = CallId('unknown-call') + expect(toPiContext(request([ + { role: 'user', content: [] }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'answer' }, + { type: 'tool-call', id: CallId('other-call'), name: 'lookup', arguments: '{}' }, + ], + }, + { + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text: 'result' }], + }], + }, + ]))).toMatchObject({ + messages: [ + { role: 'user', content: '' }, + { role: 'assistant' }, + { role: 'toolResult', toolName: 'unknown' }, + ], + }) + }) + + it('handles in-history system and assistant messages explicitly on the image path', async () => { + await expect(toPiContext(request([{ + role: 'system', + content: [{ type: 'image', attachment: ref }], + }]), attachments)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + + await expect(toPiContext(request([ + { role: 'system', content: [{ type: 'text', text: 'history system' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'answer' }] }, + { role: 'user', content: [{ type: 'text', text: 'plain' }] }, + ]), attachments)).resolves.toMatchObject({ + messages: [ + { role: 'user', content: 'history system' }, + { role: 'assistant' }, + { role: 'user', content: 'plain' }, + ], + }) + + expect(() => toPiAssistant({ + role: 'assistant', + content: [{ type: 'image', attachment: ref }], + })).toThrow(/assistant image output/) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 06154a8a5e..6bee0aa0d0 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -1,5 +1,13 @@ +import { readFile } from 'node:fs/promises' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' +import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' import LlmService, { CallId } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' @@ -17,6 +25,8 @@ interface ProviderCase { const openAIBaseURL = process.env.DSH_PI_AI_OPENAI_BASE_URL const azureOpenAIKey = process.env.AZURE_OPENAI_API_KEY +const anthropicApiKey = process.env.ANTHROPIC_API_KEY ?? process.env.DEEPSEEK_API_KEY +const anthropicBaseURL = process.env.DSH_PI_AI_ANTHROPIC_BASE_URL ?? process.env.DEEPSEEK_BASE_URL const providerCases: ProviderCase[] = [ { @@ -32,13 +42,14 @@ const providerCases: ProviderCase[] = [ provider: 'anthropic', api: 'anthropic-messages', model: process.env.DSH_PI_AI_ANTHROPIC_MODEL ?? 'claude-opus-4-8', - ...process.env.ANTHROPIC_API_KEY ? { apiKey: process.env.ANTHROPIC_API_KEY } : {}, + ...anthropicApiKey === undefined ? {} : { apiKey: anthropicApiKey }, + ...anthropicBaseURL === undefined ? {} : { baseURL: anthropicBaseURL }, }, ] const contexts: Context[] = [] -async function harness(): Promise { +async function harness(image?: StoredImageAttachment): Promise { const ctx = new Context() contexts.push(ctx) await ctx.plugin(LlmService) @@ -50,6 +61,30 @@ async function harness(): Promise { ...profile.headers === undefined ? {} : { headers: profile.headers }, })), }) + if (image !== undefined) { + const fixture = image + class E2eAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: fixture.data.byteLength, + maxImagesPerMessage: 1, + maxMessageImageBytes: fixture.data.byteLength, + maxImagePixels: fixture.ref.width * fixture.ref.height, + mediaTypes: [fixture.ref.mediaType], + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('e2e attachment fixture is read-only')) + } + + readImage(ref: ImageAttachmentRef): Promise { + if (ref.attachmentId !== fixture.ref.attachmentId) { + return Promise.reject(new Error('unknown e2e attachment fixture')) + } + return Promise.resolve(fixture) + } + } + await ctx.plugin(E2eAttachmentStore) + } return ctx } @@ -158,6 +193,41 @@ for (const profile of providerCases) { expect(textOf(second).toLowerCase()).toContain('ocean') expect(expectNativeReplay(second, profile).stopReason).toBe('stop') }) + + if (profile.provider === 'anthropic') { + it('sends a real image through the authenticated Anthropic visual path', async () => { + const data = new Uint8Array(await readFile( + new URL('../../../../assets/community-wecom-survey.png', import.meta.url), + )) + const ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: data.byteLength, + width: 256, + height: 256, + name: 'qr-code.png', + } + const ctx = await harness({ ref, data }) + const result = await assemble(ctx, { + provider: profile.provider, + model: profile.model, + messages: [{ + role: 'user', + content: [ + { + type: 'text', + text: 'What type of machine-readable symbol is shown in the attached image? Reply with exactly: QR code', + }, + { type: 'image', attachment: ref, alt: 'machine-readable symbol' }, + ], + }], + maxTokens: 256, + }) + + expectFinish(result, 'stop') + expect(textOf(result).toLowerCase()).toContain('qr code') + }) + } }, ) } diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index e3618474e2..063fd3559f 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' @@ -116,6 +117,16 @@ describe('TokenMeterService pricing', () => { const blocks: ContentBlock[] = [ { type: 'text', text: 'abcd' }, { type: 'reasoning', text: 'ab' }, + { + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1024, + height: 513, + }, + }, { type: 'tool-call', id: CallId('c'), name: 'read', arguments: '{"x":1}' }, { type: 'tool-result', @@ -126,7 +137,7 @@ describe('TokenMeterService pricing', () => { { type: 'future-block', payload: 'abcd' } as unknown as ContentBlock, ] const estimated = service.estimateMessage({ role: 'assistant', content: blocks }) - expect(estimated).toBeGreaterThan(30) + expect(estimated).toBe(813) expect(service.estimateMessage(textMessage('abcd'))).toBe(9) }) diff --git a/packages/ui/acp/tests/codec.spec.ts b/packages/ui/acp/tests/codec.spec.ts index 9cd2ca33a1..30a3ec2f15 100644 --- a/packages/ui/acp/tests/codec.spec.ts +++ b/packages/ui/acp/tests/codec.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { TurnEndReason } from '@deepseek-ai/dsh-session' import { SessionId } from '@deepseek-ai/dsh-session' @@ -35,6 +36,17 @@ describe('turnEndToStopReason', () => { describe('harnessBlockToAcpContent', () => { it('maps a text block to ACP text content', () => { expect(harnessBlockToAcpContent({ type: 'text', text: 'hi' })).toEqual({ type: 'text', text: 'hi' }) + const attachmentId = AttachmentId(`sha256:${'a'.repeat(64)}`) + expect(harnessBlockToAcpContent({ + type: 'image', + attachment: { + attachmentId, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + })).toEqual({ type: 'text', text: `[image attachment ${attachmentId}]` }) }) it('returns undefined for non-text blocks (reasoning / plugin-added)', () => { diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 28d45f50d6..03aeea7d06 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -124,6 +124,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`. -- **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering. +- **Images use explicit text markers** — the terminal cannot render inline raster images, so user, assistant, tool-result, and streaming image blocks render as `[image attachment ]` instead of disappearing. - **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback. - **Manual `/skill:` invocation always reloads the full skill body** — the TUI does not detect a skill already present in the conversation, so repeated invocations append its instructions again. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 4a16aa87d0..9ffe081bf4 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -424,6 +424,9 @@ function contentText(content: readonly ContentBlock[]): string { case 'tool-result': parts.push(contentText(block.content)) break + case 'image': + parts.push(`[image attachment ${block.attachment.attachmentId}]`) + break default: { const rawType = (block as { type?: unknown }).type parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`) @@ -648,7 +651,14 @@ class AssistantMessageComponent extends Container { constructor(content: readonly ContentBlock[], showReasoning: boolean, palette: Palette, mdTheme: MarkdownTheme) { super() const reasoning = displayText(textBlocks(content, 'reasoning').trim()) - const text = displayText(textBlocks(content, 'text').trim()) + const text = displayText(content + .flatMap(block => block.type === 'text' + ? [block.text] + : block.type === 'image' + ? [`[image attachment ${block.attachment.attachmentId}]`] + : []) + .join('\n\n') + .trim()) if (reasoning && showReasoning) { this.addChild(new Spacer(1)) this.addChild(new Text(palette.italic(palette.muted('Reasoning')), 1, 0)) @@ -668,6 +678,7 @@ class AssistantMessageComponent extends Container { interface StreamingBlock { type: string text: string + block?: ContentBlock } class StreamingAssistantComponent extends Container { @@ -691,6 +702,8 @@ class StreamingAssistantComponent extends Container { this.blocks.set(chunk.index, block) } else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) { this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text }) + } else if (chunk.type === 'block-end' && chunk.block.type === 'image') { + this.blocks.set(chunk.index, { type: 'image', text: '', block: chunk.block }) } this.rebuild() } @@ -707,6 +720,7 @@ class StreamingAssistantComponent extends Container { .flatMap(([, block]) => { if (block.type === 'text') return [{ type: 'text', text: block.text }] if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] + if (block.type === 'image' && block.block?.type === 'image') return [block.block] return [] }) const component = new AssistantMessageComponent(content, this.showReasoning, this.palette, this.mdTheme) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 22a50c9ccf..bd3c76c6fb 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -434,6 +434,29 @@ describe('pi-tui chat lifecycle and transcript', () => { step: 1, chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' }, }) + result.session.append('assistant/chunk', { + turn: 3, + step: 1, + chunk: { type: 'block-start', index: 3, blockType: 'image' }, + }) + result.session.append('assistant/chunk', { + turn: 3, + step: 1, + chunk: { + type: 'block-end', + index: 3, + block: { + type: 'image', + attachment: { + attachmentId: 'sha256:stream-image' as never, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, + }, + }) result.session.append('assistant/chunk', { turn: 3, step: 1, @@ -441,6 +464,7 @@ describe('pi-tui chat lifecycle and transcript', () => { }) await tick() expect(result.terminal.output).toContain('live thought') + expect(result.terminal.output).toContain('sha256:stream-image]') result.terminal.send('\x12') await tick() appendAssistant( @@ -729,6 +753,16 @@ describe('pi-tui chat lifecycle and transcript', () => { { type: 'text', text: '# Heading\n\n[link](https://example.com) `code`\n\n```ts\nconst x = 1\n```\n\n> quote\n\n---\n\n- item\n\n**bold** *italic* ~~strike~~' }, { type: 'tool-call', id: 'nested' as never, name: 'nested_tool', arguments: '{}' }, { type: 'tool-result', toolCallId: 'nested' as never, content: [{ type: 'reasoning', text: 'nested result' }] }, + { + type: 'image', + attachment: { + attachmentId: 'sha256:user-image' as never, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, { type: 'future-block' } as never, {} as never, ], @@ -737,6 +771,16 @@ describe('pi-tui chat lifecycle and transcript', () => { appendAssistant(session, [ { type: 'reasoning', text: 'styled reasoning' }, { type: 'text', text: 'styled answer' }, + { + type: 'image', + attachment: { + attachmentId: 'sha256:assistant-image' as never, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, ], { inputTokens: 2_000_000, outputTokens: 1_500_000 }) session.append('todo/write', { todos: [ { content: 'done', status: 'completed' }, @@ -756,6 +800,8 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Heading') expect(result.terminal.output).toContain('nested_tool({})') expect(result.terminal.output).toContain('nested result') + expect(result.terminal.output).toContain('sha256:user-image]') + expect(result.terminal.output).toContain('[image attachment sha256:assistant-image]') expect(result.terminal.output).toContain('[future-block]') expect(result.terminal.output).toContain('[content]') expect(result.terminal.output).toContain('↑2.0m ↓1.5m') diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index a3c16f1241..0f58cbd059 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -8,6 +8,13 @@ import { expect } from 'vitest' import { RegistryService } from 'cordis' import type { Context, Plugin } from 'cordis' +import { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' import InvariantService from '@deepseek-ai/dsh-invariants' declare global { @@ -78,6 +85,25 @@ export function usesManualInvariantTree(testPath: string): boolean { } const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const +const ATTACHMENT_COMPANION = '../packages/attachment/attachment-local/src/invariant.ts' + +class TestAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: 1, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1, + maxImagePixels: 1, + mediaTypes: ['image/png'], + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('test invariant attachment store does not save images')) + } + + readImage(_ref: ImageAttachmentRef): Promise { + return Promise.reject(new Error('test invariant attachment store does not read images')) + } +} /** * Select the package companions that an ordinary test root must register. @@ -115,6 +141,7 @@ function startInvariantHost(root: Context): InvariantHost { mount(InvariantService, { enabled: true }) const testPath = expect.getState().testPath ?? '' const companionPaths = testInvariantCompanionPaths(testPath) + if (companionPaths.includes(ATTACHMENT_COMPANION)) mount(TestAttachmentStore) for (const path of companionPaths) { const companion = testInvariantCompanions[path] if (companion === undefined) { From 304f034c83fb38d7fdcbdb7810c85a0007dfc964 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 24 Jul 2026 20:07:40 +0800 Subject: [PATCH 003/229] fix(ci): repair branch gates after the master merge - regenerate docs/module-graph.md for the attachment packages - pack @deepseek-ai/dsh-attachment in the packed-install e2e closure so npm resolves the new dsh-llm peer from the tarball set instead of the registry - gate the pi-ai anthropic e2e profile strictly on ANTHROPIC_API_KEY: the DeepSeek endpoint does not serve anthropic-messages, so the DEEPSEEK_API_KEY fallback turned the keyless skip into a 404 across the whole suite --- .claude/worktrees/provider-file-ids | 1 + docs/module-graph.md | 32 +++++++++++++------ .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 6 ++-- .../sandbox-local/tests/packed-install.e2e.ts | 1 + 4 files changed, 29 insertions(+), 11 deletions(-) create mode 160000 .claude/worktrees/provider-file-ids diff --git a/.claude/worktrees/provider-file-ids b/.claude/worktrees/provider-file-ids new file mode 160000 index 0000000000..7069d37059 --- /dev/null +++ b/.claude/worktrees/provider-file-ids @@ -0,0 +1 @@ +Subproject commit 7069d37059db3a48dec1ef748bb71ada9a85d5d4 diff --git a/docs/module-graph.md b/docs/module-graph.md index 212fd723a7..b77129f8b3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -131,6 +131,10 @@ flowchart TD pkg_user_approval["user-approval"] pkg_user_interaction["user-interaction"] end + subgraph group_attachment["packages/attachment"] + pkg_attachment["attachment"] + pkg_attachment_local["attachment-local"] + end subgraph group_client["packages/client"] pkg_client_connection["client-connection"] pkg_client_hmr["client-hmr"] @@ -230,14 +234,10 @@ flowchart TD pkg_host_apiproxy --> pkg_invariants pkg_host_runtime --> pkg_invariants pkg_host_webserver --> pkg_invariants - pkg_llm --> pkg_brand - pkg_llm --> pkg_invariants + pkg_attachment --> pkg_brand + pkg_attachment --> pkg_invariants pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_invariants - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_invariants @@ -250,9 +250,21 @@ flowchart TD pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths + pkg_llm --> pkg_attachment + pkg_llm --> pkg_brand + pkg_llm --> pkg_invariants + pkg_attachment_local --> pkg_attachment + pkg_attachment_local --> pkg_invariants + pkg_attachment_local --> pkg_paths + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_invariants pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_timeout @@ -805,15 +817,17 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-runtime`](../packages/host/runtime) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`invariants`](../packages/support/invariants) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `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-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`attachment`](../packages/attachment/attachment), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 6bee0aa0d0..85dcbd5706 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -25,8 +25,10 @@ interface ProviderCase { const openAIBaseURL = process.env.DSH_PI_AI_OPENAI_BASE_URL const azureOpenAIKey = process.env.AZURE_OPENAI_API_KEY -const anthropicApiKey = process.env.ANTHROPIC_API_KEY ?? process.env.DEEPSEEK_API_KEY -const anthropicBaseURL = process.env.DSH_PI_AI_ANTHROPIC_BASE_URL ?? process.env.DEEPSEEK_BASE_URL +// Strictly ANTHROPIC_*: the DeepSeek endpoint does not serve the anthropic-messages +// protocol, so falling back to DEEPSEEK_API_KEY turns the keyless skip into a 404. +const anthropicApiKey = process.env.ANTHROPIC_API_KEY +const anthropicBaseURL = process.env.DSH_PI_AI_ANTHROPIC_BASE_URL const providerCases: ProviderCase[] = [ { diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 9fcfe23de8..e453aad6a4 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -25,6 +25,7 @@ const WORKSPACE_CLOSURE = [ 'packages/sandbox/sandbox-local', 'packages/sandbox/sandbox', 'packages/llm/llm', + 'packages/attachment/attachment', 'packages/util/brand', 'packages/support/invariants', ] From b868355f012d316b3d21ea7583ddb58a1f09be62 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 24 Jul 2026 20:08:08 +0800 Subject: [PATCH 004/229] chore: drop accidentally committed .claude/worktrees gitlink and ignore the directory --- .claude/worktrees/provider-file-ids | 1 - .gitignore | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 160000 .claude/worktrees/provider-file-ids diff --git a/.claude/worktrees/provider-file-ids b/.claude/worktrees/provider-file-ids deleted file mode 160000 index 7069d37059..0000000000 --- a/.claude/worktrees/provider-file-ids +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7069d37059db3a48dec1ef748bb71ada9a85d5d4 diff --git a/.gitignore b/.gitignore index ae9b4b5ddd..b714a02b38 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ python/**/__pycache__/ python/**/.pytest_cache/ apps/web/dist/ .artifacts/ +.claude/worktrees/ From f57a4a044ad036b6b46718af12e163f655e3391c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 24 Jul 2026 20:55:25 +0800 Subject: [PATCH 005/229] fix(gui): address ds-review-bot findings on image attachments - dsh web gains --provider/--model: a non-deepseek provider mounts the matching pi-ai catalog route (ambient credentials), making image input reachable from the shipped Web assembly; requires an explicit --model - attachment-local syncs the publication directories after the hard-link publish so a reported durable reference survives a crash (POSIX; Windows relies on filesystem metadata journaling) - the attachment seam gains storage-free validateImage; the host validates a complete multi-image prompt before persisting any member, so one malformed image cannot strand valid members as unreferenced objects - startSession sends before navigating: a rejected first send keeps the empty state, its error strip, and the complete draft mounted - the webserver rejects an undeclared-length body the moment it crosses the configured limit instead of draining a potentially endless stream to EOF --- ...ge-input-and-durable-attachments.i18n.yaml | 4 +-- ...dal-image-input-and-durable-attachments.md | 10 +++--- ...-image-input-and-durable-attachments.zh.md | 10 +++--- apps/cli/README.md | 2 +- apps/cli/src/web.ts | 20 ++++++++++- docs/cordis-catalog/services.md | 8 +++++ .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/src/index.ts | 8 +++-- .../attachment/attachment-local/src/store.ts | 33 +++++++++++++++++++ .../attachment-local/tests/index.spec.ts | 19 +++++++++++ packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/src/index.ts | 8 +++++ .../src/client/contract/slots.ts | 6 +++- .../ui-conversation/src/client/service.ts | 18 +++++----- .../tests/service-orchestration.spec.ts | 18 +++++++--- .../cordis/tool-cordis/src/api-catalog.ts | 4 +++ packages/host/runtime/src/api-proxy.ts | 10 ++++++ .../host/runtime/tests/host-runtime.spec.ts | 25 ++++++++++++++ packages/host/webserver/src/index.ts | 18 +++++----- .../host/webserver/tests/webserver.spec.ts | 21 ++++++++++++ packages/llm/llm-pi-ai/tests/adapter.spec.ts | 4 +++ .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 4 +++ scripts/test-invariants.ts | 4 +++ 23 files changed, 217 insertions(+), 41 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 5d438dfc9d..19d5dbc04a 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 156bec4a4f9bb05490847cbe775368bc472a33e9 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: fb0f8d3031bb2d0e6af97136892cd07c22d575b3 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: a88cc0fd460bcc2b7f29c8cf5a3b9a91c5fc10e6 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: ad2b8724e677c61121356921382d99d6e8741a0b diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 156bec4a4f..a88cc0fd46 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -26,7 +26,7 @@ Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-onl - The rail is shared by the empty-state and resident composers, is hidden when empty, and scrolls horizontally instead of widening the composer. - Each approximately 72-by-72-pixel thumbnail has a remove action and opens its original draft image on double-click. - A prompt may contain text and images or images only. Pure text paste remains native browser behavior; mixed clipboard content inserts its text normally while adding its files to the rail, and file-only paste prevents default browser handling. File drops on the composer always prevent browser navigation and report unsupported files locally. -- A failed send restores the complete text and image draft. Removal, successful send, empty-state disposal, rendered-session disposal, and application disposal revoke the object URLs they own. +- A failed send restores the complete text and image draft. The empty state navigates to the new session only after its first send is accepted, so a rejected send keeps the draft and its error surface mounted. Removal, successful send, empty-state disposal, rendered-session disposal, and application disposal revoke the object URLs they own. - Historical user and assistant images use one `MessageImage` control. Inline images preserve intrinsic aspect ratio, do not upscale, and stay within a 240-by-240-pixel box. - Double-clicking a message image opens the stored original in a viewport-bounded modal. Escape, the close control, and backdrop activation close it and restore focus. - Version one does not override the browser context menu and provides no explicit image-copy action. @@ -63,7 +63,7 @@ interface ComposerAttachment { This split uses the slots framework's store seat and bound actions as the single subscription path for UI state while keeping non-serializable browser objects out of persisted JSON. Draft text and ordered image identifiers continue to use `localStorage`; after a reload, `ConversationRoot` prunes identifiers whose runtime objects no longer exist. Unsent images therefore do not survive reload because browser `File` and object URLs are not durable. A native client may stage input in an OS temporary directory, but it must treat that path exactly like the browser object URL: delete it when no longer needed and copy the bytes into the durable store before message acceptance. -The local attachment backend resolves an explicit `dshHome`, then `$DSH_HOME`, then `~/.dsh`. It stores content-addressed objects below `$DSH_HOME/attachments/v1/objects//` with owner-only directory and file permissions. A temporary file is written, synchronized, and atomically published before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier, and every read verifies the digest, media type, byte length, width, and height. +The local attachment backend resolves an explicit `dshHome`, then `$DSH_HOME`, then `~/.dsh`. It stores content-addressed objects below `$DSH_HOME/attachments/v1/objects//` with owner-only directory and file permissions. A temporary file is written, synchronized, atomically published, and made durable with a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier, and every read verifies the digest, media type, byte length, width, and height. The store performs no automatic deletion in version one. Sent user images and model-generated images remain reachable for history, resume, and fork. Reference-aware garbage collection needs a separate design because an age-only rule can delete data still referenced by a durable session. Deployment byte and pixel limits are admission policy on writes; reads verify the digest and recorded metadata without reapplying current admission limits, so lowering policy does not invalidate older history. @@ -108,7 +108,7 @@ type PromptInputPart = } ``` -Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, magic-byte MIME, intrinsic dimensions, and decoded-pixel count. Only after every image succeeds does it call the agent with normalized text and durable image blocks. A failure appends no user event and exposes no attachment path or raw bytes. +Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, magic-byte MIME, intrinsic dimensions, and decoded-pixel count — the complete batch, through the seam's storage-free `validateImage`, before persisting any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Only after every image succeeds does it call the agent with normalized text and durable image blocks. A failure appends no user event and exposes no attachment path or raw bytes. `session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client deduplicates loads by session and attachment identifier while that session is rendered, revokes resolved URLs on rendered-session disposal, and invalidates late loads so an unmounted session cannot repopulate the cache. @@ -118,7 +118,7 @@ Model catalog entries gain optional merge-extensible input and output modality d The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. `host.describe` projects the default model and image limits into `SessionsService`; both composers use the limits before allocating object URLs or base64, while only the no-session composer uses the default model for early explicit text-only feedback. Decoded-pixel validation and every resident session's actual route remain authoritative on the host. -The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, then resolves each durable reference and emits native image content only for models that declare image input. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. +The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, then resolves each durable reference and emits native image content only for models that declare image input. The shipped Web assembly reaches it through `dsh web --provider --model `, which mounts that pi-ai catalog route with the provider's ambient credentials; the DeepSeek-only default remains text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. @@ -134,7 +134,7 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state ### Limits and trust boundaries -Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and are projected to the client for fast-path guidance; host validation remains authoritative. The Web carrier independently caps buffered API request bodies, with `dsh web` deriving its default from the aggregate image limit plus base64/envelope expansion and allowing an explicit override. +Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and are projected to the client for fast-path guidance; host validation remains authoritative. The Web carrier independently caps buffered API request bodies, with `dsh web` deriving its default from the aggregate image limit plus base64/envelope expansion and allowing an explicit override; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. Malformed base64, unsupported or mismatched media, truncated headers, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index fb0f8d3031..ad2b8724e6 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -26,7 +26,7 @@ Status: implemented - 空状态输入区与常驻输入区共用附件栏;附件栏为空时隐藏,通过横向滚动避免撑宽输入区。 - 每个缩略图约为 72 × 72 像素,带有移除操作;双击时打开草稿原图。 - 提示词可同时包含文本与图片,也可仅包含图片。粘贴纯文本时保持浏览器原生行为;粘贴混合的剪贴板内容时,文本会正常插入,文件则同时添加到附件栏;仅粘贴文件时才阻止浏览器的默认处理。在输入区放置文件时总会阻止浏览器导航,并在本地报告不受支持的文件。 -- 发送失败时恢复完整的文本与图片草稿。移除、发送成功、空状态释放、已渲染会话释放和应用释放都会撤销各自持有的对象 URL。 +- 发送失败时恢复完整的文本与图片草稿。空状态只有在首次发送被接受后才导航到新会话,因此发送被拒绝时草稿及其错误提示仍保持挂载。移除、发送成功、空状态释放、已渲染会话释放和应用释放都会撤销各自持有的对象 URL。 - 历史用户图片与助手图片共用一个 `MessageImage` 控件。行内图片保持固有宽高比、不放大,并限制在 240 × 240 像素的边界框内。 - 双击消息图片会在不超出视口的模态框中打开存储的原图。按 Escape、激活关闭控件或激活背景区域都会关闭模态框并恢复焦点。 - 第一版不覆盖浏览器上下文菜单,也不提供明确的图片复制操作。 @@ -63,7 +63,7 @@ interface ComposerAttachment { 这一拆分让 UI 状态通过 slots 框架的 store 席位和绑定 actions 使用唯一的订阅路径,同时避免把不可序列化的浏览器对象写进持久 JSON。草稿文本和有序图片标识符继续使用 `localStorage`;重载后,`ConversationRoot` 会清理缺少对应运行时对象的标识符。未发送图片因此无法跨重载保留,因为浏览器 `File` 与对象 URL 不具备持久性。原生客户端可以在操作系统临时目录中暂存输入,但必须像对待浏览器对象 URL 一样对待该路径:不再需要时删除,并在消息被接受前把字节复制进持久存储。 -本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。服务先写入并同步临时文件,再以原子方式发布,之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中,每次读取都会校验摘要、媒体类型、字节长度、宽度和高度。 +本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。服务先写入并同步临时文件,再以原子方式发布,并对发布目录执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中,每次读取都会校验摘要、媒体类型、字节长度、宽度和高度。 第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。部署的字节和像素限制是写入时的准入策略;读取时会校验摘要和已记录的元数据,但不重新应用当前准入限制,因此收紧策略不会导致旧历史记录失效。 @@ -108,7 +108,7 @@ type PromptInputPart = } ``` -Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和解码像素数。只有每张图片都成功后,宿主才会用规范化文本和持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 +Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和解码像素数——并在持久化任何成员之前,通过服务边界上不触碰存储的 `validateImage` 校验完整批次,因此一张畸形图片不会把批次中的有效成员留成无引用的孤儿对象。只有每张图片都成功后,宿主才会用规范化文本和持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 `session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并使延迟完成的加载失效,以免已卸载的会话重新写入缓存。 @@ -118,7 +118,7 @@ Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规 宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。`host.describe` 把默认模型和图片限制投影到 `SessionsService`;两种输入区都会在分配对象 URL 或 base64 前使用这些限制,只有无会话输入区会使用默认模型,针对明确仅支持文本的情况提前反馈。解码像素校验与每个常驻会话的实际路由均由宿主作出权威判定。 -Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 +Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的 Web 组装通过 `dsh web --provider --model ` 到达这条路径——该命令用提供方的环境凭据挂载对应的 pi-ai 目录路由;仅含 DeepSeek 的默认组装仍是纯文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 @@ -134,7 +134,7 @@ token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为 ### 限制与信任边界 -第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引;宿主校验仍是权威结果。Web 载体会独立限制 API 请求体的缓冲大小;`dsh web` 根据图片总量限制加上 base64 和请求封装的膨胀量推导默认值,并允许显式覆盖。 +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引;宿主校验仍是权威结果。Web 载体会独立限制 API 请求体的缓冲大小;`dsh web` 根据图片总量限制加上 base64 和请求封装的膨胀量推导默认值,并允许显式覆盖;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 格式错误的 base64、不支持或不匹配的媒体、截断的文件头、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 diff --git a/apps/cli/README.md b/apps/cli/README.md index e6a33247ca..89390a6dff 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -10,7 +10,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web surface treats its invoking directory as the default project, loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opts into first-message model titles. The headless surface retains deterministic fallback titles without making the auxiliary title-model request. +The Web surface treats its invoking directory as the default project, loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opts into first-message model titles. `dsh web --provider --model ` selects the default agent route; a non-`deepseek` provider additionally mounts that pi-ai catalog route with the provider's ambient credentials (e.g. `ANTHROPIC_API_KEY`), which is how image input reaches a visual-capable model, and requires an explicit `--model`. The headless surface retains deterministic fallback titles without making the auxiliary title-model request. ## Install (developer machine) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index ea7ed1f2eb..97da4734c1 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -49,6 +49,8 @@ export async function runWeb(argv: string[]): Promise { host: { type: 'string', default: LOOPBACK_HOST }, port: { type: 'string', default: '3080' }, 'max-request-body-bytes': { type: 'string' }, + provider: { type: 'string' }, + model: { type: 'string' }, dev: { type: 'boolean', default: false }, }, allowPositionals: false, @@ -77,12 +79,28 @@ export async function runWeb(argv: string[]): Promise { process.exit(1) } - // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). + // Default routing: --provider deepseek (implicit) keeps the DeepSeek-only + // assembly; any other --provider additionally mounts that pi-ai catalog + // route (credentials via the provider's ambient discovery, e.g. + // ANTHROPIC_API_KEY) so visual-capable models are reachable from the shipped + // Web app. A non-default provider requires an explicit --model — this shell + // has no evidence for inventing another provider's default. + const provider = values.provider ?? 'deepseek' + if (provider !== 'deepseek' && values.model === undefined) { + process.stderr.write(`dsh web: --provider ${provider} requires an explicit --model\n`) + process.exit(1) + } + + // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught + // by design); an unknown --provider fails the pi-ai catalog check the same way. const host = await startHost({ boot: { persistenceRoot: './.sessions', workspaceContext: { maxBytes: 65_536 }, sessionTitleLlm: true, + ...provider === 'deepseek' ? {} : { piAiProviders: [{ provider }] }, + ...values.provider === undefined ? {} : { provider: values.provider }, + ...values.model === undefined ? {} : { model: values.model }, }, }) const attachments = host.ctx.get('attachments') diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0c6e930a44..51297f8a45 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -253,6 +253,14 @@ Source: [`packages/ui/user-approval/src/index.ts:213`](../../packages/ui/user-ap Immutable binary attachment service. Implementations validate bytes before publishing a reference. ```ts cordis-catalog +/** + * Validate one image against the deployment policy without persisting anything. + * Callers persisting a multi-image batch validate every member first so a + * malformed member cannot leave earlier members as unreferenced objects. + * @param input - encoded bytes, declared media type, and optional display name. + */ +abstract validateImage(input: SaveImageAttachment): void + /** * Validate and durably commit one image before its owning session event is appended. * @param input - encoded bytes, declared media type, and optional display name. diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 3e867695d6..d1c4327203 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-attachment-local -The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Writes use a private staging directory, owner-only files, a synced temporary file, and an atomic exclusive hard-link publish; reads re-check the digest, media signature, dimensions, and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. +The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash; reads re-check the digest, media signature, dimensions, and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index acb06f16c9..18ce810f7b 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -6,10 +6,10 @@ import z from 'schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-paths' -import { readImageFile, saveImageFile } from './store.ts' +import { readImageFile, saveImageFile, validateImageFile } from './store.ts' export { detectImage } from './image.ts' -export { readImageFile, saveImageFile } from './store.ts' +export { readImageFile, saveImageFile, validateImageFile } from './store.ts' export { AttachmentError } from '@deepseek-ai/dsh-attachment' export type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' @@ -62,6 +62,10 @@ export class LocalAttachmentStore extends AttachmentStore { }) } + validateImage(input: SaveImageAttachment): void { + validateImageFile(input, this.imageLimits) + } + async saveImage(input: SaveImageAttachment): Promise { return saveImageFile(this.root, input, this.imageLimits) } diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 22e2ca2309..34431f5a2b 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -52,6 +52,32 @@ function validateAdmission(metadata: Omit { + /* v8 ignore next -- Windows cannot open directory handles; NTFS metadata journaling owns entry durability there. */ + if (process.platform === 'win32') return + const handle = await open(path, constants.O_RDONLY) + try { + await handle.sync() + } finally { + await handle.close() + } +} + /** * Save and verify immutable image bytes below a versioned attachment root. * @param root - absolute `DSH_HOME/attachments/v1` root. @@ -86,6 +112,13 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li const existing = new Uint8Array(await readFile(target)) if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') } + // The synced file becomes durable only once its directory entries are: sync + // the bucket (the new object entry) and its parent (the possibly new bucket + // entry) before this reference can reach a session checkpoint. The dedup + // path syncs too — the earlier save that created the entry may have crashed + // before its own directory sync. + await syncDirectory(bucket) + await syncDirectory(join(root, 'objects')) await unlink(temporary) } catch (error) { /* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */ diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index b21b0d544d..b99df8b179 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -1,4 +1,5 @@ import { Context } from 'cordis' +import { existsSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -36,4 +37,22 @@ describe('local attachment service', () => { await rm(dshHome, { recursive: true, force: true }) } }) + + it('validates without persisting: a rejected image leaves no storage root behind', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-')) + try { + const service = new LocalAttachmentStore(new Context(), { dshHome }) + expect(() => { service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }) }) + .toThrow(/Unsupported or malformed image data/) + const valid = Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', + )) + expect(() => { service.validateImage({ data: valid, mediaType: 'image/png' }) }).not.toThrow() + // Validation is storage-free: nothing below the root may exist yet. + expect(existsSync(service.root)).toBe(false) + } finally { + await rm(dshHome, { recursive: true, force: true }) + } + }) }) diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 2fcc99c477..11e2d4035f 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -2,7 +2,7 @@ The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `saveImage` is called only at message submission or while committing structured provider output, before any model-visible session event is published. `readImage` verifies the content-addressed object against its logged metadata. +Unsent composer images remain browser-owned temporary drafts. `saveImage` is called only at message submission or while committing structured provider output, before any model-visible session event is published. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so one malformed member cannot strand earlier members as unreferenced objects (there is no garbage collection). `readImage` verifies the content-addressed object against its logged metadata. ## Model Experience diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 000856be78..8ddf7dd1d1 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -33,6 +33,14 @@ export abstract class AttachmentStore extends Service { /** Deployment-resolved image policy used by authoritative and fast-path validation. */ abstract readonly imageLimits: ImageAttachmentLimits + /** + * Validate one image against the deployment policy without persisting anything. + * Callers persisting a multi-image batch validate every member first so a + * malformed member cannot leave earlier members as unreferenced objects. + * @param input - encoded bytes, declared media type, and optional display name. + */ + abstract validateImage(input: SaveImageAttachment): void + /** * Validate and durably commit one image before its owning session event is appended. * @param input - encoded bytes, declared media type, and optional display name. diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 6b46086be3..7bea1c7e33 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -187,7 +187,11 @@ export interface EmptyStateInjected { releaseDraftImage(id: string): void /** Release all service-owned image previews held by the empty state. */ releaseDraftImages(attachments: readonly ComposerAttachment[]): void - /** The create → navigate → first-send chain, in one service call. */ + /** + * The create → first-send → navigate chain, in one service call. Navigation + * happens only after the send is accepted, so a failure leaves the empty + * state and its draft mounted. + */ startSession(opts: { cwd?: string text: string diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 0336809191..f6b0582d85 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -209,12 +209,12 @@ export class ConversationService extends Service { /** * Empty-state first-send chain (root-context method; does not read scope): - * create the session, navigate to it, then send through the new scope. - * The create → open ordering is safe: the manager merges the new summary - * synchronously before create() resolves, so the list store is projected by - * the time open() validates against it (manager notification batching is - * microtask-based; SessionsService projects on the same flush that create - * awaited through the RPC round trip). + * create the session, send through the new scope, and navigate only after + * the send is accepted. Navigation is the publication point — opening + * earlier would unmount the empty state (releasing its draft previews) + * while the send can still fail, leaving the failure with no surface and + * the user with a lost draft; on rejection here the still-mounted empty + * state keeps the draft and shows the error locally. * @param opts - project directory, prompt text, images, and send mode. */ async startSession(opts: { @@ -226,9 +226,10 @@ export class ConversationService extends Service { const sessions = this.requireSessions() const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd }) // The manager notifier flushes per microtask; one await guarantees the - // list-store projection landed before sessions.open validates against it. + // list-store projection landed before sessions.open validates against it + // (the manager merges the new summary synchronously before create() + // resolves; batching is microtask-based). await Promise.resolve() - sessions.open(id) const scoped = sessions.scope(id) if (scoped === undefined) throw new Error(`conversation.startSession: created session "${id}" resolved no scope`) // ctx.get, not scoped.conversation: property access walks the fiber @@ -237,6 +238,7 @@ export class ConversationService extends Service { const scopedConversation = scoped.get('conversation') if (scopedConversation === undefined) throw new Error('conversation.startSession: conversation service unavailable through the new scope') await scopedConversation.send(opts.text, opts.mode, opts.images ?? []) + sessions.open(id) } /** Resolve the caller scope's Session or throw on root contexts. */ diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 1362fbd851..bc4c0200d0 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -2,7 +2,7 @@ /** * ConversationService orchestration half after the store-seat slimming: * scope-addressed send/cancel (result folding, root throw), the startSession - * chain (create → sessions.open → scoped send), and the service-unavailable + * chain (create → scoped send → sessions.open), and the service-unavailable * loud failures. Selection/draft state left this service for the declared * chat store (chat-store.spec.ts / selection-survival.spec.ts); the view * registry left for the 'conversation.view' slot (views-type-chain.spec.tsx). @@ -280,13 +280,23 @@ describe('image admission and URL lifecycle', () => { }) describe('startSession chain', () => { - it('creates, navigates through sessions.open, then sends through the new scope', async () => { + it('creates, sends through the new scope, then navigates through sessions.open', async () => { const b = await bench() await b.svc.startSession({ cwd: '/proj', text: 'first', mode: 'queue' }) expect(b.createMock).toHaveBeenCalledWith({ cwd: '/proj' }) expect(b.openMock).toHaveBeenCalledWith(sid('new-1')) - expect(b.sessionDoubles.get(sid('new-1'))!.prompt).toHaveBeenCalledWith( - [{ type: 'text', text: 'first' }], 'queue') + const prompt = b.sessionDoubles.get(sid('new-1'))!.prompt + expect(prompt).toHaveBeenCalledWith([{ type: 'text', text: 'first' }], 'queue') + // Navigation is the publication point: it must not precede send acceptance. + expect(b.openMock.mock.invocationCallOrder[0]!).toBeGreaterThan(prompt.mock.invocationCallOrder[0]!) + }) + + it('does not navigate when the first send is rejected (empty state keeps the draft)', async () => { + const b = await bench() + const doomed = b.sessionsFake.manager.get(sid('new-1')) as unknown as SessionDouble + doomed.prompt.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'nope' } }) + await expect(b.svc.startSession({ text: 'first', mode: 'queue' })).rejects.toThrow(/agent-busy/) + expect(b.openMock).not.toHaveBeenCalled() }) it('omits cwd from create when not chosen', async () => { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 0ef2082f2a..cf4bcbff5c 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -156,6 +156,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'attachments', summary: 'Immutable binary attachment service.', methods: [ + { + signature: 'abstract validateImage(input: SaveImageAttachment): void', + jsDoc: '/**\n * Validate one image against the deployment policy without persisting anything.\n * Callers persisting a multi-image batch validate every member first so a\n * malformed member cannot leave earlier members as unreferenced objects.\n * @param input - encoded bytes, declared media type, and optional display name.\n */', + }, { signature: 'abstract saveImage(input: SaveImageAttachment): Promise', jsDoc: '/**\n * Validate and durably commit one image before its owning session event is appended.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns a durable content-addressed reference.\n */', diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 5ca1e853b1..b56a49aab0 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -54,6 +54,16 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten if (totalBytes > limits.maxMessageImageBytes) { throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') } + // Validate the complete batch before persisting any member: the store has no + // garbage collection, so one malformed image must not leave the batch's + // valid members as published objects no message event will ever reference. + for (const image of images) { + ctx.attachments.validateImage({ + data: image.data, + mediaType: image.part.mediaType, + ...image.part.name === undefined ? {} : { name: image.part.name }, + }) + } return Promise.all(prepared.map(async (item): Promise => { if (!('data' in item)) return { type: 'text', text: item.text } const attachment = await ctx.attachments.saveImage({ diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index f7cf357431..2abc8ba48f 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -614,6 +614,31 @@ describe('sessions.prompt / cancel', () => { }) }) + it('publishes nothing when one member of a multi-image prompt is malformed', async () => { + const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-batch-session-')) + const dshHome = mkdtempSync(join(tmpdir(), 'dsh-batch-home-')) + host = await startHost({ + boot: { persistenceRoot, workspaceContext: false, dshHome, provider: 'scripted', model: 'test-model' }, + }) + host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('unused')])) + const { sessionId } = expectOk(await host.api.sessions.create(request({}))) + const response = await host.api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [ + { type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64 }, + // Canonical base64, but the bytes are not a PNG: the whole batch must + // be validated before any member persists, or the valid image above + // would become a permanently unreferenced object (this store has no GC). + { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQID' }, + ], + })) + expect(response.result).toMatchObject({ + ok: false, error: { details: { reason: 'INVALID_IMAGE' } }, + }) + expect(existsSync(join(dshHome, 'attachments'))).toBe(false) + }) + it('rejects images for an explicitly text-only model without creating a session event', async () => { const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-text-session-')) const dshHome = mkdtempSync(join(tmpdir(), 'dsh-text-home-')) diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 77c7bf4301..9406b66634 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -214,21 +214,19 @@ async function bridge( } const chunks: Buffer[] = [] let received = 0 - let oversized = false for await (const chunk of req) { const buffer = chunk as Buffer received += buffer.byteLength if (received > maxRequestBodyBytes) { - oversized = true - chunks.length = 0 - continue + // Reject the moment the threshold is crossed: draining a chunked body to + // EOF first would let a client without Content-Length stream + // indefinitely while holding the socket and this request task. + res.writeHead(413, { connection: 'close' }) + res.end() + req.destroy() + return } - if (!oversized) chunks.push(buffer) - } - if (oversized) { - res.writeHead(413) - res.end() - return + chunks.push(buffer) } /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server requests; the fields are only optional on the client-side IncomingMessage type */ diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 63552127bc..c6ab019564 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -435,6 +435,27 @@ describe('/api bridge', () => { expect(status).toBe(413) }) + it('rejects an unterminated chunked body at the threshold without draining to EOF', async () => { + const base = await boot(() => undefined, 8) + const target = new URL(`${base}/api/echo`) + // The client never calls end(): the 413 must arrive the moment the limit + // is crossed, or a hostile stream would hold the socket open forever. + const status = await new Promise((resolve, reject) => { + const request = httpRequest({ + hostname: target.hostname, + port: target.port, + path: target.pathname, + method: 'POST', + }, (response) => { + response.resume() + response.on('end', () => { resolve(response.statusCode) }) + }) + request.on('error', reject) + request.write('123456789') + }) + expect(status).toBe(413) + }) + it('relays a bodyless response', async () => { const base = await boot() const response = await fetch(`${base}/api/empty`, { method: 'POST' }) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index de12d81e5e..749627e961 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -226,6 +226,10 @@ describe('PiAiAdapter provider routing', () => { mediaTypes: ['image/png'], } + validateImage(_input: SaveImageAttachment): void { + throw new Error('not used') + } + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('not used')) } diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 85dcbd5706..09e21dea6e 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -74,6 +74,10 @@ async function harness(image?: StoredImageAttachment): Promise { mediaTypes: [fixture.ref.mediaType], } + validateImage(_input: SaveImageAttachment): void { + throw new Error('e2e attachment fixture is read-only') + } + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('e2e attachment fixture is read-only')) } diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 0f58cbd059..c843f94a5a 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -96,6 +96,10 @@ class TestAttachmentStore extends AttachmentStore { mediaTypes: ['image/png'], } + validateImage(_input: SaveImageAttachment): void { + throw new Error('test invariant attachment store does not validate images') + } + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('test invariant attachment store does not save images')) } From dbd93fed023f5c696a1114f77c7197611a63faa1 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 24 Jul 2026 21:42:27 +0800 Subject: [PATCH 006/229] test: follow send-unify event shape and refresh the cordis-inspect snapshot - host-runtime attachment-authorization tests append injected context as user/message with a plugin source (context/message was folded by send-unify) - refresh cordis-inspect-jsdoc expected outputs for the AttachmentStore validateImage seam addition --- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- .../snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl | 2 +- packages/host/runtime/tests/host-runtime.spec.ts | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 487f0517b1..bc2e50cb82 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n alt?: string;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 56cc640977..23ea901fcb 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter or leave plan mode","input":{"hint":"[off|message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n alt?: string;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index d6c965dc00..1a27e1fd23 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -496,7 +496,8 @@ describe('sessions.prompt / cancel', () => { const { sessionId: nestedSession } = expectOk(await host.api.sessions.create(request({}))) const nestedAgent = host.ctx.agents.get(nestedSession) as Agent - nestedAgent.session.append('context/message', { + // Injected context is a user/message with a non-user source (send-unify). + nestedAgent.session.append('user/message', { content: [ null, [], @@ -511,7 +512,7 @@ describe('sessions.prompt / cancel', () => { content: [{ type: 'image', attachment: image.attachment }], }, ] as never, - source: { kind: 'user' }, + source: { kind: 'plugin', plugin: 'spec' }, }, { surfaceOp: 'append' }) expectOk(await host.api.sessions.attachment(request({ sessionId: nestedSession, @@ -534,9 +535,9 @@ describe('sessions.prompt / cancel', () => { ...image.attachment, attachmentId: `sha256:${'b'.repeat(64)}` as never, } - streamedAgent.session.append('context/message', { + streamedAgent.session.append('user/message', { content: [{ type: 'image', attachment: missingRef }], - source: { kind: 'user' }, + source: { kind: 'plugin', plugin: 'spec' }, }, { surfaceOp: 'append' }) const missing = await host.api.sessions.attachment(request({ sessionId: streamedSession, From 4db6628416d5d80ced44b4a4f2b8d11168f37152 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 25 Jul 2026 23:23:11 +0800 Subject: [PATCH 007/229] fix(ci): refresh multimodal merge coverage --- docs/module-graph.md | 32 +++++++++++++------ packages/acp/acp/tests/turns.spec.ts | 29 +++++++++++++++++ .../host/apiproxy/tests/fetch-carrier.spec.ts | 1 + 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 9e8b3adf8a..d7b83d278f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -133,6 +133,10 @@ flowchart TD pkg_user_approval["user-approval"] pkg_user_interaction["user-interaction"] end + subgraph group_attachment["packages/attachment"] + pkg_attachment["attachment"] + pkg_attachment_local["attachment-local"] + end subgraph group_client["packages/client"] pkg_client_connection["client-connection"] pkg_client_hmr["client-hmr"] @@ -240,17 +244,13 @@ flowchart TD pkg_host_apiproxy --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants - pkg_llm --> pkg_brand - pkg_llm --> pkg_invariants + pkg_attachment --> pkg_brand + pkg_attachment --> pkg_invariants pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_invariants @@ -273,9 +273,21 @@ flowchart TD pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_invariants pkg_storage_sqlite --> pkg_storage + pkg_llm --> pkg_attachment + pkg_llm --> pkg_brand + pkg_llm --> pkg_invariants + pkg_attachment_local --> pkg_attachment + pkg_attachment_local --> pkg_invariants + pkg_attachment_local --> pkg_paths + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_invariants pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_timeout @@ -813,10 +825,9 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `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-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `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-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | @@ -825,8 +836,11 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`attachment`](../packages/attachment/attachment), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index cf081d1f2a..adef80adc2 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -38,6 +38,35 @@ describe('ACP prompt lifecycle', () => { await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') }) }) + it('renders an assistant image as an explicit attachment placeholder', async () => { + const attachmentId = `sha256:${'a'.repeat(64)}` as never + harness = await makeBridgeHarness({ + script: [[ + { type: 'block-start', index: 0, blockType: 'image' }, + { + type: 'block-end', + index: 0, + block: { + type: 'image', + attachment: { + attachmentId, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, + }, + { type: 'finish', reason: { kind: 'stop' } }, + ]], + }) + const sessionId = await newSession(harness) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] }) + await vi.waitFor(() => { + expect(messageText(harness!)).toBe(`[image attachment ${String(attachmentId)}]`) + }) + }) + it('rejects a failed turn and never publishes its partial chunks', async () => { harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] }) const sessionId = await newSession(harness) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index b2234db578..3740696e88 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -96,6 +96,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const c = client() expect((await c.sessions.create({})).result.ok).toBe(true) expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) + expect((await c.sessions.attachment({ sessionId: 's' as never, attachmentId: 'a' as never })).result.ok).toBe(true) expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true) expect((await c.host.describe({})).result.ok).toBe(true) }) From 1a27c0f7797c3c5a50ad646c261ff91d82ed6f7d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 25 Jul 2026 23:27:34 +0800 Subject: [PATCH 008/229] refactor(gui): reuse image serialization --- packages/client/ui-conversation/src/client/service.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 2f55eabf78..e0b030f8e5 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -73,12 +73,7 @@ export class ConversationService extends Service { async send(text: string, mode: 'queue' | 'steer', images: readonly File[] = []): Promise { const session = this.scopedSession('send') this.validateImages(images, []) - const uploaded = await Promise.all(images.map(async file => ({ - type: 'image' as const, - mediaType: imageMediaType(file.type), - data: bytesToBase64(new Uint8Array(await file.arrayBuffer())), - ...(file.name === '' ? {} : { name: file.name }), - }))) + const uploaded = await this.serializeImages(images) const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] const result = await session.prompt(content, mode) if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`) From a1835c3228682bc459556ab2264ebd52ec23c18b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 27 Jul 2026 14:29:19 +0800 Subject: [PATCH 009/229] fix(gui): close attachment durability gaps --- .../attachment/attachment-local/src/store.ts | 36 ++++++++---- .../attachment-local/tests/store.spec.ts | 37 +++++++++++- packages/host/apiproxy/src/api/host.schema.ts | 4 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 56 ++++++++++++++++++- .../host/apiproxy/tests/rpc-schemas.spec.ts | 25 ++++++++- 5 files changed, 142 insertions(+), 16 deletions(-) diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 34431f5a2b..e64647bee2 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto' import { constants } from 'node:fs' import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises' -import { basename, join } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' import { AttachmentError, AttachmentId, @@ -78,6 +78,25 @@ async function syncDirectory(path: string): Promise { } } +/** + * Create one private directory tree and persist every newly published ancestor. + * @param path - absolute directory to create. + */ +async function ensureDurableDirectory(path: string): Promise { + const target = resolve(path) + const firstCreated = await mkdir(target, { recursive: true, mode: 0o700 }) + await chmod(target, 0o700) + if (firstCreated === undefined) return + + const highestCreated = resolve(firstCreated) + let created = target + while (true) { + await syncDirectory(dirname(created)) + if (created === highestCreated) return + created = dirname(created) + } +} + /** * Save and verify immutable image bytes below a versioned attachment root. * @param root - absolute `DSH_HOME/attachments/v1` root. @@ -91,10 +110,8 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li const sha256 = digest(input.data) const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') - await mkdir(bucket, { recursive: true, mode: 0o700 }) - await mkdir(staging, { recursive: true, mode: 0o700 }) - await chmod(bucket, 0o700) - await chmod(staging, 0o700) + await ensureDurableDirectory(bucket) + await ensureDurableDirectory(staging) const temporary = join(staging, randomUUID()) const target = objectPath(root, sha256) let handle @@ -112,11 +129,10 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li const existing = new Uint8Array(await readFile(target)) if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') } - // The synced file becomes durable only once its directory entries are: sync - // the bucket (the new object entry) and its parent (the possibly new bucket - // entry) before this reference can reach a session checkpoint. The dedup - // path syncs too — the earlier save that created the entry may have crashed - // before its own directory sync. + // Persist the target entry and close a concurrent bucket-creation window + // before the reference can reach a session checkpoint. The dedup path + // repeats both syncs because it may observe another writer's link before + // that writer reaches its own durability boundary. await syncDirectory(bucket) await syncDirectory(join(root, 'objects')) await unlink(temporary) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index d7ee6de31b..73ebb3bdec 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -1,12 +1,26 @@ import { createHash } from 'node:crypto' +import { constants } from 'node:fs' import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { mkdtemp, rm } from 'node:fs/promises' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import { readImageFile, saveImageFile } from '../src/store.ts' +const fsControl = vi.hoisted(() => ({ syncedDirectories: [] as string[] })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters): ReturnType { + if (args[1] === constants.O_RDONLY) fsControl.syncedDirectories.push(String(args[0])) + return actual.open(...args) + }, + } +}) + const PNG = Uint8Array.from(Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', @@ -33,6 +47,27 @@ afterEach(async () => { }) describe('local attachment store', () => { + it.skipIf(process.platform === 'win32')('syncs every newly created object ancestor before returning', async () => { + const storageRoot = await root() + const base = join(storageRoot, '..', '..') + const sha256 = createHash('sha256').update(PNG).digest('hex') + const objects = join(storageRoot, 'objects') + const bucket = join(objects, sha256.slice(0, 2)) + fsControl.syncedDirectories.length = 0 + + await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + + expect(fsControl.syncedDirectories).toEqual([ + objects, + storageRoot, + join(storageRoot, '..'), + base, + storageRoot, + bucket, + objects, + ]) + }) + it('publishes one private content-addressed object and deduplicates equal bytes', async () => { const storageRoot = await root() const first = await saveImageFile(storageRoot, { diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index 4b484ae594..4e42ad55ce 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -3,11 +3,13 @@ */ import { z } from 'zod' +import type { ModelModality } from '@deepseek-ai/dsh-llm' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import { imageMediaTypeSchema } from './sessions.schema.ts' -const modalitySchema = z.union([z.literal('text'), z.literal('image')]) +/** Merge-extensible modality passthrough: declaration merging cannot extend a runtime Zod union. */ +const modalitySchema = z.string() as unknown as z.ZodType /** host.describe request payload (empty object literal). */ export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 9f4f11b9df..00616da0b8 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -1,12 +1,24 @@ import { describe, expect, it, vi } from 'vitest' import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts' +import type { ResponseValue } from '../src/api/rpc-map.ts' import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { toFetchHandler } from '../src/fetch/handler.ts' import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts' +declare module '@deepseek-ai/dsh-llm' { + interface ModelModalityMap { + audio: 'audio' + } +} + /** Minimal in-memory ApiProxy: echoes rpcIds, scripts one frame per stream. */ -function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFrame[]; crashOn: string }> = {}): ApiProxy { +function fakeApi(overrides: Partial<{ + muxFrames: MuxFrame[] + hostFrames: HostFrame[] + crashOn: string + hostDescription: ResponseValue<'host.describe'> +}> = {}): ApiProxy { const muxFrames = overrides.muxFrames ?? [{ type: 'session/subscribed', sessionId: 's1' as never, lastSeq: -1 }] const hostFrames = overrides.hostFrames ?? [{ type: 'host/session-removed', sessionId: 's1' as never }] async function * stream(frames: F[], signal: AbortSignal): AsyncGenerator> { @@ -45,7 +57,13 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, host: { async describe(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } } + return { + rpcId: request.rpcId, + result: { + ok: true, + value: overrides.hostDescription ?? { version: 'v', cwd: '/w', attachedSessions: 0 }, + }, + } }, }, workspace: { @@ -137,6 +155,40 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect((await c.host.describe({})).result.ok).toBe(true) }) + it('round-trips a declaration-merged model modality through host.describe', async () => { + const c = client(fakeApi({ + hostDescription: { + version: 'v', + cwd: '/w', + activeModel: { + provider: 'future', + id: 'audio-model', + name: 'Audio Model', + inputModalities: ['text', 'audio'], + outputModalities: ['audio'], + }, + attachedSessions: 0, + }, + })) + + const response = await c.host.describe({}) + expect(response.result).toEqual({ + ok: true, + value: { + version: 'v', + cwd: '/w', + activeModel: { + provider: 'future', + id: 'audio-model', + name: 'Audio Model', + inputModalities: ['text', 'audio'], + outputModalities: ['audio'], + }, + attachedSessions: 0, + }, + }) + }) + it('round-trips command.list / command.execute / skill.list through the wire form', async () => { const c = client() const list = await c.commands.list({ sessionId: 's' as never }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index d257020298..042e5aa2a0 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -155,11 +155,32 @@ describe('sessions domain schemas', () => { }) describe('host domain schemas', () => { - it('validates describe request/value', () => { + it('validates describe request/value and preserves merge-extensible modalities', () => { expect(hostDescribeRequestSchema.parse({})).toEqual({}) - const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2 }) + const value = hostDescribeValueSchema.parse({ + version: '1', + cwd: '/x', + provider: 'p', + model: 'm', + activeModel: { + provider: 'p', + id: 'm', + name: 'Model', + inputModalities: ['text', 'audio'], + outputModalities: ['text', 'audio'], + }, + attachedSessions: 2, + }) expect(value.attachedSessions).toBe(2) + expect(value.activeModel?.inputModalities).toEqual(['text', 'audio']) + expect(value.activeModel?.outputModalities).toEqual(['text', 'audio']) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined() + expect(() => hostDescribeValueSchema.parse({ + version: '1', + cwd: '/x', + activeModel: { provider: 'p', id: 'm', name: 'Model', inputModalities: [{ type: 'audio' }] }, + attachedSessions: 0, + })).toThrow() }) }) From 7e68cf898614737b5fe41bce55dd1114d8aa5a9f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 14:45:46 +0800 Subject: [PATCH 010/229] test: cover the readAttachment and hostDescription bench stubs directly --- packages/client/test-runtime/tests/runtime.spec.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 22d3bb5cfc..21bef157b5 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -467,9 +467,12 @@ describe('fixture session face', () => { await runtime.sessions.add({ id: 's1' }) const bare = runtime.sessions.behavior('s1') expect(() => bare.prompt()).toThrow(/prompt is not stubbed/) + expect(() => bare.readAttachment('att-1' as Parameters[0])).toThrow(/readAttachment is not stubbed/) expect(() => bare.cancel()).toThrow(/cancel is not stubbed/) expect(() => bare.command()).toThrow(/command is not stubbed/) expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/) + // No host handshake exists in the bench unless a fixture supplies one. + expect(runtime.sessions.hostDescription()).toBeUndefined() await runtime.dispose() }) From ae94d32c35d4a6866a01724589f6f1d06d8e22b9 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 17:53:13 +0800 Subject: [PATCH 011/229] fix(fixture): follow the message-event shape and keep todo_write the alpha log tail The multimodal sample turn carried the pre-send-unify event shape (bare content/provenance instead of the message wrapper), crashing the built client fold; it also sat after the todo_write turn, retiring the plan projection the TodoPanel tests pin. Wrap both messages through the fixture helpers and order the image turn before the todo turn; re-record the code-mode trajectory ordinals the two added message nodes shift. --- apps/web/tests/code-mode-fixture.snapshot.ts | 6 +-- .../client/connection/src/client/fixture.ts | 53 ++++++++++--------- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index ce5a98b2eb..6214332166 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -215,9 +215,9 @@ it('trajectory and waterfall surface the run_code sub-calls with real timing', a }).toMatchInlineSnapshot(` { "subCells": [ - "#51Subbash · {"command":"ls notes","description":"List notes"}+0.8s", - "#52Subread · {"path":"notes/demo.txt"}+0.8s", - "#53Subread · {"path":"notes/missing.txt"}+0.8s", + "#49Subbash · {"command":"ls notes","description":"List notes"}+0.8s", + "#50Subread · {"path":"notes/demo.txt"}+0.8s", + "#51Subread · {"path":"notes/missing.txt"}+0.8s", ], } `) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 52336a1fe7..3e16ef28fa 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -48,10 +48,10 @@ function userMessage(content: ContentBlock[], source: MessageSource = { kind: 'u return createUserMessage({ content, source }) } -function assistantMessage(content: ContentBlock[]): AssistantMessage { +function assistantMessage(content: ContentBlock[], model = 'fx-1'): AssistantMessage { return createAssistantMessage({ content, - source: { provider: 'fixture', model: 'fx-1' }, + source: { provider: 'fixture', model }, }) } @@ -228,7 +228,30 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - // Turn 65: todo_write sample — the TodoRow toolview in the flow plus the + // Turn 65: multimodal image sample — image blocks in a user message and an + // assistant message, both referencing the fixture attachment (the log + // reference authorizes the sessions.attachment fetch). Ordered BEFORE the + // todo turn: the plan projection retires at the next turn/start, so the + // todo_write turn must stay the log tail for the TodoPanel strip to show. + push({ type: 'turn/start', data: { turn: 65, trigger: { kind: 'message', source: { kind: 'user' } } } }) + push({ + type: 'user/message', + surfaceOp: 'append', + data: userMessage([{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')]), + }) + push({ type: 'step/start', data: { turn: 65, step: 0 } }) + push({ + type: 'assistant/message', + surfaceOp: 'append', + data: { + turn: 65, + step: 0, + message: assistantMessage([...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }], 'fx-vision'), + }, + }) + push({ type: 'step/end', data: { turn: 65, step: 0 } }) + push({ type: 'turn/end', data: { turn: 65, reason: { kind: 'completed' } } }) + // Turn 66: todo_write sample — the TodoRow toolview in the flow plus the // todo/write snapshot event feeding the TodoPanel plan strip. const fixtureTodos = [ { content: '梳理需求', status: 'completed' }, @@ -236,35 +259,13 @@ function buildAlphaLog(): SessionEvent[] { { content: '浏览器验收', status: 'pending' }, ] const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') + toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') // The real tool appends the snapshot mid-execution — between tool/call and // tool/result — so the fixture reproduces that exact ordering (the last // toolTurn events run ... tool/call, tool/result, step/end, turn/end). const callIndex = events.length - 4 const callTime = events[callIndex]?.time as number events.splice(callIndex + 1, 0, { type: 'todo/write', time: callTime + 400, data: { todos: fixtureTodos } }) - push({ type: 'turn/start', data: { turn: 66, trigger: { kind: 'message', source: { kind: 'user' } } } }) - push({ - type: 'user/message', - surfaceOp: 'append', - data: { - content: [{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')], - source: { kind: 'user' }, - }, - }) - push({ type: 'step/start', data: { turn: 66, step: 0 } }) - push({ - type: 'assistant/message', - surfaceOp: 'append', - data: { - turn: 66, - step: 0, - content: [...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }], - provenance: { provider: 'fixture', model: 'fx-vision' }, - }, - }) - push({ type: 'step/end', data: { turn: 66, step: 0 } }) - push({ type: 'turn/end', data: { turn: 66, reason: { kind: 'completed' } } }) events.forEach((e, i) => { e.seq = i }) return events as unknown as SessionEvent[] } From e7b30799fec0bc57994f5fcb28c6e36d36fefdbc Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 17:58:30 +0800 Subject: [PATCH 012/229] chore: retrigger CI (dropped webhook for 010d948aa) From adce3b833d66f1425cfcdbe8420870a3601c913f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 18:56:40 +0800 Subject: [PATCH 013/229] fix: address ds-review-bot v6/v7 findings on the image-input assembly - resolveLlmRoute: reuse the yml pi-ai row for providers it already routes (DUPLICATE_ADAPTER boot failure) and detect an unset model by origin, not by comparison against one deployment default; covered by a new spec. - LlmService.resolveModelInfoFor preserves (and validates) modality metadata, arming the host image preflight for exact-route resolution. - session.selectModel refuses a text-only target once the session log carries an image on any replayed route; an accepted switch would strand every later turn with no in-product recovery. - The composer no longer gates image intake on the handshake activeModel snapshot (wrong authority for a per-session decision); the host preflight plus the error strip own capability, deployment limits stay client-side. - InputHub shell teardown releases the scope's draft images (File objects and object URLs leaked for the page lifetime). - session.prompt image parts carry optional alt into the durable block; ImageBlock documents assistant-side rendering as forward compatibility. - Assembled built-client lane apps/web/tests/image-display.snapshot.ts pins the history galleries over the authorized attachment route, the lightbox, and the composer paste rail; the attachment rail is an accessible group. - Docs: validateImage on the seam page, fixture byte metadata matches its PNG, and the Agent Note claims now match the shipped coverage. --- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 6 +- ...-image-input-and-durable-attachments.zh.md | 6 +- apps/cli/src/app-cli-entry.ts | 74 +++++++- apps/cli/tests/llm-route.spec.ts | 61 ++++++ apps/web/tests/image-display.snapshot.ts | 177 ++++++++++++++++++ .../core-data-structures/attachment.i18n.yaml | 4 +- docs/core-data-structures/attachment.md | 2 +- docs/core-data-structures/attachment.zh.md | 2 +- .../client/connection/src/client/fixture.ts | 4 +- .../ui-conversation/src/client/input/hub.ts | 6 + .../ui-conversation/src/client/service.ts | 10 +- .../src/client/skeleton/InputBar.tsx | 2 +- .../tests/service-orchestration.spec.ts | 27 ++- packages/host/apiproxy/src/api-proxy.ts | 42 ++++- .../host/apiproxy/src/api/sessions.schema.ts | 2 +- packages/host/apiproxy/src/api/sessions.ts | 2 +- .../apiproxy/tests/api-proxy-models.spec.ts | 87 +++++++++ packages/llm/llm/src/index.ts | 12 ++ packages/llm/llm/src/types.ts | 9 +- packages/llm/llm/tests/service.spec.ts | 23 +++ 21 files changed, 526 insertions(+), 36 deletions(-) create mode 100644 apps/cli/tests/llm-route.spec.ts create mode 100644 apps/web/tests/image-display.snapshot.ts diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 20d85c2712..c58ffcd573 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 746ae8a5ffb111866c99c6aad5cb0906f252481e -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 5061e8e29a14790d22456a44e44fa162d244f116 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 2b676c1965dbef0bdc24ba6d5b4af5bf66840780 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: f13648051bcf4c81c8b032897645c9b6aeb0d32a diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 746ae8a5ff..2b676c1965 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -120,7 +120,7 @@ Base64 crosses JSON-RPC once and is discarded after persistence. The host valida Model catalog entries gain optional merge-extensible input and output modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. -The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. `host.describe` projects the active model and image limits into `SessionsService`; the resident `InputBar` uses them before allocating object URLs or base64 for fast feedback. Decoded-pixel validation and the session's actual route remain authoritative on the host. +The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. `host.describe` projects the host-default active model and the image limits into `SessionsService`; the composer applies only the deployment limits before allocating object URLs or base64. Model capability is deliberately not gated client-side: the handshake snapshot cannot represent a session's current target after `session.selectModel`, so the host preflight is the sole capability authority and its rejection renders through the composer error strip. Decoded-pixel validation and the session's actual route remain authoritative on the host. The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, then resolves each durable reference and emits native image content only for models that declare image input. The shipped Web assembly reaches it through `dsh web --provider --model `, which mounts that pi-ai catalog route with the provider's ambient credentials; the DeepSeek-only default remains text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. @@ -194,8 +194,8 @@ UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalog ## Testing - Storage tests cover content-addressed deduplication, private permissions, admission failures, corruption/missing-object failures, and reading history after deployment limits are lowered. -- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, and bounded HTTP request bodies. -- Client unit and assembled Chromium tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, historical user and assistant images, original preview, ordering, and draft/session/application object-URL cleanup. +- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, and refusal of a text-only `session.selectModel` once the session log carries an image (an accepted switch would strand every later turn). +- Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, and draft/session-scope/application object-URL cleanup; the keyless assembled built-client lane (`apps/web/tests/image-display.snapshot.ts`, `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail. - Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, nested tool-result images, preserved summary input, and explicit image-output rejection. - A credentialed real-API test sends a PNG through the Anthropic `claude-opus-4-8` route and requires the model to identify its QR code. - The current production adapter set declares text-only output; output-provider certification remains outside version one. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index 5061e8e29a..f13648051b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -120,7 +120,7 @@ Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规 模型目录项增加可选且可合并扩展的输入与输出模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。`host.describe` 把当前模型和图片限制投影到 `SessionsService`;常驻 `InputBar` 会在分配对象 URL 或 base64 前使用这些信息提供快速反馈。解码像素校验与会话的实际路由仍由宿主作出权威判定。 +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。`host.describe` 把宿主默认的当前模型和图片限制投影到 `SessionsService`;composer 在分配对象 URL 或 base64 前只应用部署级限制。模型能力刻意不在客户端把关:握手快照无法表达 `session.selectModel` 之后会话的当前目标,因此宿主前置检查是唯一的能力权威,其拒绝通过 composer 错误条呈现。解码像素校验与会话的实际路由仍由宿主作出权威判定。 Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的 Web 组装通过 `dsh web --provider --model ` 到达这条路径——该命令用提供方的环境凭据挂载对应的 pi-ai 目录路由;仅含 DeepSeek 的默认组装仍是纯文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 @@ -194,8 +194,8 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模 ## 测试 - 存储测试覆盖内容寻址去重、私有权限、准入失败、对象损坏或缺失时的失败,以及收紧部署限制后读取历史数据。 -- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制,以及大小受限的 HTTP 请求体。 -- 客户端单元测试和组装后的 Chromium 测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、历史用户与助手图片、原图预览、顺序,以及草稿、会话和应用层级的对象 URL 清理。 +- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体,以及在会话日志已含图片时拒绝切换到纯文本模型的 `session.selectModel`(接受该切换会让此后每一轮都失败)。 +- 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序,以及草稿、会话作用域和应用层级的对象 URL 清理;keyless 的组装后构建产物通道(`apps/web/tests/image-display.snapshot.ts`,`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。 - 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、嵌套工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。 - 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG,并要求模型识别其中的二维码。 - 当前生产适配器集合声明仅支持文本输出;输出提供方认证不在第一版范围内。 diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 3fbbd1970a..4bed5bbba7 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -61,6 +61,61 @@ export function resolveLanTrust( return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } } +/** One provider/model source layer for {@link resolveLlmRoute}, in override order. */ +export interface LlmRouteInput { + /** CLI flag values (highest precedence). */ + cli: { provider?: string | undefined; model?: string | undefined } + /** Profile-json values (parsed JSON — validated here, the config boundary). */ + profile: { provider?: unknown; model?: unknown } + /** The api-gateway yml row's config values (deployment defaults). */ + gateway: { provider?: unknown; model?: unknown } + /** Providers the shipped yml already routes through its static pi-ai row. */ + ymlPiAiProviders: readonly string[] +} + +/** The boot's resolved LLM routing decision. */ +export interface LlmRoute { + /** Effective api-gateway provider. */ + provider: string + /** Pi-ai provider to mount dynamically; undefined when DeepSeek or a yml-routed provider serves the request. */ + dynamicPiAiProvider: string | undefined +} + +/** + * Resolve the boot's LLM route from the layered provider/model sources. + * A non-DeepSeek provider requires a model set at least as explicitly as the + * provider itself (flag/profile) — origin decides, never a comparison against + * any deployment's default model value, so editing the yml default cannot + * silently disarm the guard. Providers the shipped yml pi-ai row already + * routes are NOT mounted again: `LlmService.registerAdapter` rejects + * duplicate routes, so the gateway provider/model patch alone selects them. + * @param input - the layered provider/model sources and the yml pi-ai roster. + * @returns the effective provider and the dynamic pi-ai mount decision. + */ +export function resolveLlmRoute(input: LlmRouteInput): LlmRoute { + const provider = input.cli.provider ?? input.profile.provider ?? input.gateway.provider + if (typeof provider !== 'string' || provider === '') { + throw new Error('dsh: api-gateway provider must be a non-empty string') + } + if (provider !== 'deepseek') { + const providerFromYml = input.cli.provider === undefined && input.profile.provider === undefined + // A yml-set provider trusts its own row pairing; an override must bring + // its model along instead of inheriting the yml default's. + const model = providerFromYml + ? input.gateway.model + : input.cli.model ?? input.profile.model + if (typeof model !== 'string' || model === '') { + throw new Error(`dsh: provider ${provider} requires an explicit model`) + } + } + return { + provider, + dynamicPiAiProvider: provider === 'deepseek' || input.ymlPiAiProviders.includes(provider) + ? undefined + : provider, + } +} + /** One profile-json key mapped onto a yml row's config field. */ interface ProfileMapping { jsonPath: string @@ -207,15 +262,16 @@ export class AppCLIEntry { if (this.options.model !== undefined) put('api-gateway', 'model', this.options.model) const gatewayConfig = rows.get('api-gateway')?.config as Record | undefined - const provider = this.options.provider ?? profile.provider ?? gatewayConfig?.provider - const model = this.options.model ?? profile.model ?? gatewayConfig?.model - if (typeof provider !== 'string' || provider === '') { - throw new Error('dsh: api-gateway provider must be a non-empty string') - } - if (provider !== 'deepseek' && (typeof model !== 'string' || model === '' || model === 'deepseek-v4-flash')) { - throw new Error(`dsh: provider ${provider} requires an explicit model`) - } - this.piAiProvider = provider === 'deepseek' ? undefined : provider + const piAiRow = rows.get('llm-pi-ai')?.config as { providers?: { provider?: unknown }[] } | undefined + const route = resolveLlmRoute({ + cli: { provider: this.options.provider, model: this.options.model }, + profile: { provider: profile.provider, model: profile.model }, + gateway: { provider: gatewayConfig?.provider, model: gatewayConfig?.model }, + ymlPiAiProviders: (piAiRow?.providers ?? []) + .map(p => p.provider) + .filter((value): value is string => typeof value === 'string'), + }) + this.piAiProvider = route.dynamicPiAiProvider // Source 2b: authorities for the /api browser-trust fence (rationale on // resolveLanTrust). diff --git a/apps/cli/tests/llm-route.spec.ts b/apps/cli/tests/llm-route.spec.ts new file mode 100644 index 0000000000..7dfc8adf41 --- /dev/null +++ b/apps/cli/tests/llm-route.spec.ts @@ -0,0 +1,61 @@ +/** resolveLlmRoute: layered provider/model resolution and the dynamic pi-ai mount decision. */ +import { describe, expect, it } from 'vitest' +import { resolveLlmRoute } from '../src/app-cli-entry.ts' + +/** The shipped yml shape: DeepSeek gateway default plus a pi-ai row routing openai/anthropic. */ +const SHIPPED = { + gateway: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + ymlPiAiProviders: ['openai', 'anthropic'], +} + +describe('resolveLlmRoute', () => { + it('keeps the DeepSeek default without any dynamic mount', () => { + expect(resolveLlmRoute({ cli: {}, profile: {}, ...SHIPPED })) + .toEqual({ provider: 'deepseek', dynamicPiAiProvider: undefined }) + }) + + it('reuses the yml pi-ai row for providers it already routes (no duplicate adapter)', () => { + expect(resolveLlmRoute({ + cli: { provider: 'anthropic', model: 'claude-opus-4-8' }, profile: {}, ...SHIPPED, + })).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined }) + }) + + it('mounts pi-ai dynamically only for providers absent from the yml row', () => { + expect(resolveLlmRoute({ + cli: { provider: 'google', model: 'gemini-3-pro' }, profile: {}, ...SHIPPED, + })).toEqual({ provider: 'google', dynamicPiAiProvider: 'google' }) + }) + + it('requires an explicit model wherever the provider override came from, by origin', () => { + // CLI provider with no CLI/profile model: the yml DeepSeek default must not leak in. + expect(() => resolveLlmRoute({ cli: { provider: 'anthropic' }, profile: {}, ...SHIPPED })) + .toThrow(/provider anthropic requires an explicit model/) + // Profile provider paired with a profile model is explicit enough. + expect(resolveLlmRoute({ + cli: {}, profile: { provider: 'openai', model: 'gpt-5' }, ...SHIPPED, + })).toEqual({ provider: 'openai', dynamicPiAiProvider: undefined }) + // Profile provider with only the yml default model: same gap, same refusal. + expect(() => resolveLlmRoute({ cli: {}, profile: { provider: 'openai' }, ...SHIPPED })) + .toThrow(/provider openai requires an explicit model/) + }) + + it('trusts a yml-set non-DeepSeek provider only when its own row carries the model', () => { + expect(resolveLlmRoute({ + cli: {}, profile: {}, + gateway: { provider: 'anthropic', model: 'claude-opus-4-8' }, + ymlPiAiProviders: ['openai', 'anthropic'], + })).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined }) + expect(() => resolveLlmRoute({ + cli: {}, profile: {}, + gateway: { provider: 'anthropic' }, + ymlPiAiProviders: ['openai', 'anthropic'], + })).toThrow(/provider anthropic requires an explicit model/) + }) + + it('fails loud on a missing or empty provider', () => { + expect(() => resolveLlmRoute({ cli: {}, profile: {}, gateway: {}, ymlPiAiProviders: [] })) + .toThrow(/provider must be a non-empty string/) + expect(() => resolveLlmRoute({ cli: { provider: '' }, profile: {}, ...SHIPPED })) + .toThrow(/provider must be a non-empty string/) + }) +}) diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts new file mode 100644 index 0000000000..b0d4125eac --- /dev/null +++ b/apps/web/tests/image-display.snapshot.ts @@ -0,0 +1,177 @@ +// @vitest-environment jsdom +// Multimodal image surfaces over the BUILT client graph (the code-mode-fixture +// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). +// Opens the fixture history session whose turn 65 carries an image in BOTH a +// user message and an assistant message, and pins the product surfaces: the +// history ImageGallery loading real fixture bytes through the authorized +// sessions.attachment route, the double-click ImageLightbox, and the composer +// intake chain (paste → thumbnail rail → image-only send enablement → remove). +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' + +const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { + id: '@deepseek-ai/dsh-client-ui-workspace', + dir: 'ui-workspace', + url: '/plugins/ui-workspace.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-sidebar', + ], + }, +] + +const bundles = new Map(PLUGINS.map(plugin => [ + plugin.url, + readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), +])) + +interface FixtureWindow extends Window { + __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] } + __ModuleLoader__?: unknown +} + +class ResizeObserverStub { + observe(): void {} + disconnect(): void {} + unobserve(): void {} +} + +const win = window as FixtureWindow +let unmount: (() => void) | undefined + +beforeEach(() => { + localStorage.clear() + document.title = 'DeepSeek Harness' + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => { callback(0) }, 0) as unknown as number) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) +}) + +afterEach(() => { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.__ModuleLoader__ + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + document.title = '' + history.replaceState(null, '', '/') + vi.unstubAllGlobals() +}) + +/** Boot the complete built client graph against the populated fixture branch. */ +function boot(): void { + history.replaceState(null, '', '/?fixture') + const root = document.createElement('div') + root.id = 'root' + document.body.appendChild(root) + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } + act(() => { + const entry = new AppWebEntry(root, { + fetchBundle: (url) => { + const code = bundles.get(url) + return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) + }, + executeBundle: (code) => { (0, eval)(code) }, + }) + void entry.run() + unmount = () => { entry.dispose() } + }) +} + +/** Open the fixture history session (the alpha log carrying the turn-65 image pair) and wait for its gallery. */ +async function openFixtureSession(): Promise { + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const group = (await within(tree).findAllByText('fixture')) + .map(el => el.closest('[role="treeitem"]')) + .find(el => el?.getAttribute('aria-expanded') !== null) + if (group === null || group === undefined) throw new Error('fixture Workspace group missing') + if (group.getAttribute('aria-expanded') === 'false') { + fireEvent.click(within(group).getByText('fixture')) + await waitFor(() => { + expect(group.getAttribute('aria-expanded')).toBe('true') + }) + } + const session = await within(tree).findByText('Fixture 历史会话') + fireEvent.click(session) + await waitFor(() => { + expect(document.querySelectorAll('[data-align] img').length).toBeGreaterThan(0) + }, { timeout: 10_000 }) +} + +it('renders the history image pair through the authorized attachment route and opens the lightbox', async () => { + boot() + await openFixtureSession() + + // Both the user-side (align=end) and assistant-side (align=start) galleries + // load real fixture bytes over sessions.attachment (data: fallback in jsdom). + await waitFor(() => { + const user = document.querySelector('[data-align="end"] img') + const assistant = document.querySelector('[data-align="start"] img') + if (user === null || assistant === null) throw new Error('history image galleries missing') + // jsdom serves object URLs; environments without createObjectURL fall back to data:. + expect(user.getAttribute('src')).toMatch(/^(blob:|data:image\/png;base64,)/) + expect(assistant.getAttribute('src')).toMatch(/^(blob:|data:image\/png;base64,)/) + }, { timeout: 10_000 }) + const userImage = document.querySelector('[data-align="end"] img')! + expect(userImage.getAttribute('alt')).toBe('fixture-image.png') + + // Double-click opens the original-size lightbox; Escape/close dismisses it. + const frame = userImage.closest('button') + if (frame === null) throw new Error('image frame button missing') + fireEvent.doubleClick(frame) + const lightbox = await screen.findByRole('dialog') + expect(within(lightbox).getByRole('img').getAttribute('src')).toMatch(/^(blob:|data:image\/png;base64,)/) + fireEvent.click(within(lightbox).getByRole('button', { name: /关闭/ })) + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeNull() + }) +}) + +it('accepts a pasted image into the composer rail and removes it', async () => { + boot() + await openFixtureSession() + + // Image-only send arming is pinned at package level (input-bar.spec.tsx); + // this assembled lane pins the intake chain over the built graph. + const textarea = await screen.findByPlaceholderText('Message the agent', {}, { timeout: 10_000 }) + const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' }) + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }], + getData: () => '', + }, + }) + + // The rail is an accessible group holding the draft thumbnail (queried via + // DOM: jsdom's a11y-visibility computation hides the composer subtree). + const rail = await waitFor(() => { + const el = document.querySelector('[role="group"][aria-label="待发送图片"]') + if (el === null) throw new Error('attachment rail missing') + return el + }, { timeout: 5_000 }) + expect(rail.querySelector('img')?.getAttribute('src')).toMatch(/^(blob:|data:)/) + + const remove = rail.querySelector('button[aria-label^="移除图片"]') + if (remove === null) throw new Error('remove button missing') + fireEvent.click(remove) + await waitFor(() => { + expect(document.querySelector('[role="group"][aria-label="待发送图片"]')).toBeNull() + }) +}) diff --git a/docs/core-data-structures/attachment.i18n.yaml b/docs/core-data-structures/attachment.i18n.yaml index 2edde07da0..145d21a267 100644 --- a/docs/core-data-structures/attachment.i18n.yaml +++ b/docs/core-data-structures/attachment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/attachment.md -attachment.md: bb25b62b3260dff0ae85a03819c8655213c25563 -attachment.zh.md: b9bf182ef7623d2d121f6d6190fd14de49401f67 +attachment.md: c4c974b075300025868e90f9905193d62d68c4ee +attachment.zh.md: 12802a414018d081460459ad96ee4aa91801da62 diff --git a/docs/core-data-structures/attachment.md b/docs/core-data-structures/attachment.md index bb25b62b32..c4c974b075 100644 --- a/docs/core-data-structures/attachment.md +++ b/docs/core-data-structures/attachment.md @@ -69,4 +69,4 @@ interface StoredImageAttachment { } ``` -`saveImage()` validates bytes and atomically commits one object before returning its reference. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. +`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks (magic bytes, declared media type, size and pixel limits) without persisting anything — batch callers MUST validate every member through it before persisting any, so a rejected batch leaves no partial objects behind. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. diff --git a/docs/core-data-structures/attachment.zh.md b/docs/core-data-structures/attachment.zh.md index b9bf182ef7..12802a4140 100644 --- a/docs/core-data-structures/attachment.zh.md +++ b/docs/core-data-structures/attachment.zh.md @@ -69,4 +69,4 @@ interface StoredImageAttachment { } ``` -`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 +`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行同一套准入检查(magic bytes、声明的媒体类型、大小与像素上限)但不落任何持久化——批量调用方必须先对每个成员通过它校验、再持久化任何一个,从而保证被拒绝的批次不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 3e16ef28fa..5bcf2ee608 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -108,7 +108,9 @@ const FIXTURE_IMAGE_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklE const FIXTURE_IMAGE_REF: ImageAttachmentRef = { attachmentId: 'fixture:image' as AttachmentIdType, mediaType: 'image/png', - bytes: 68, + // Matches the decoded FIXTURE_IMAGE_DATA exactly (the real backend serves + // verified metadata; a mismatched fixture would mislead comparisons). + bytes: 247, width: 160, height: 90, name: 'fixture-image.png', diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index ad8e941901..66c2b5189d 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -28,6 +28,7 @@ interface ConversationAttachmentFace { mode: 'queue' | 'steer', imageIds: readonly string[], ): Promise + releaseDraftImage(id: string): void } /** Session-addressed input facade registry (InputService face + composer-layer extras). */ @@ -84,8 +85,13 @@ export class InputHub implements InputService { ] return () => { for (const off of offs) off() + // Draft attachments die with the scope: the shell only holds ids, so + // the service-owned File objects and object URLs must be released + // here or they leak for the page lifetime. + const drafts = shell.snapshot.imageIds shell.dispose() this.shells.delete(id) + for (const imageId of drafts) this.conversation().releaseDraftImage(imageId) } }, 'conversation.input: session shell') return shell diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 7787a1e016..94afce5a85 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -330,11 +330,13 @@ export class ConversationService extends Service implements IConversation { current: readonly ComposerAttachment[], ): void { if (files.length === 0 && current.length === 0) return + // Deployment-wide limits only. Model capability is deliberately NOT + // checked here: the handshake's activeModel is the host default, not the + // session's current target (session.selectModel never refreshes it), so a + // client-side modality gate refuses sessions the host would accept and + // vice versa. The host preflight on session.prompt is the authority; its + // rejection renders through the composer error strip. const description = this.requireSessions().hostDescription() - const modalities = description?.activeModel?.inputModalities - if (modalities !== undefined && !modalities.includes('image')) { - throw new Error('当前模型不支持图片输入') - } const limits = description?.imageLimits const all = [...current.map(attachment => attachment.file), ...files] if (limits !== undefined && all.length > limits.maxImagesPerMessage) { diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 063e5bc535..524f341c9b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -385,7 +385,7 @@ export function InputBar({ {overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} {attachments.length > 0 && ( -
+
{attachments.map(attachment => (
return ( <> @@ -55,7 +54,7 @@ export function MessageImage({ attachment, alt, load }: { /** Wrapping image group shared by user and assistant history. */ export function ImageGallery({ images, load, align }: { - images: readonly { attachment: ImageAttachmentRef; alt?: string }[] + images: readonly { attachment: ImageAttachmentRef }[] load: ImageLoader align: 'start' | 'end' }) { diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 31084387df..1d5ee66a72 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -23,18 +23,17 @@ type UserImage = Extract function contentParts(content: readonly unknown[]): { text: string - images: { attachment: UserImage['attachment']; alt?: string }[] + images: { attachment: UserImage['attachment'] }[] rest: unknown[] } { const texts: string[] = [] - const images: { attachment: UserImage['attachment']; alt?: string }[] = [] + const images: { attachment: UserImage['attachment'] }[] = [] const rest: unknown[] = [] for (const block of content) { - const b = block as { type?: string; text?: string; attachment?: unknown; alt?: string } + const b = block as { type?: string; text?: string; attachment?: unknown } if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text) else if (b.type === 'image' && b.attachment !== undefined) { - const image = b as UserImage - images.push({ attachment: image.attachment, ...image.alt === undefined ? {} : { alt: image.alt } }) + images.push({ attachment: (b as UserImage).attachment }) } else rest.push(block) } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 96a66880ae..e0568ce70f 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -151,7 +151,12 @@ export class InputHub implements InputService { // see them — release the drafts here instead of resurrecting them onto // a dead instance where they would leak for the page lifetime. if (this.shells.get(session.sessionId) === shell) { - shell?.restoreImages(imageIds) + if (shell?.snapshot.imageIds.length === 0) { + shell.restoreImages(imageIds) + } else { + const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined + for (const id of imageIds) conversation?.releaseDraftImage(id) + } if (shell?.snapshot.draft === '') shell.setDraft(text) return } diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 94afce5a85..1259664b45 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -29,10 +29,9 @@ export interface IConversation { * Send a prompt into the caller scope's session. * @param text - prompt text, sent verbatim as one text block. * @param mode - queue after the current turn, or steer into it. - * @param images - browser-owned temporary images promoted by the host during this call. * @returns completion; business failures reject (and land in promptError). */ - send(text: string, mode: 'queue' | 'steer', images?: readonly File[]): Promise + send(text: string, mode: 'queue' | 'steer'): Promise /** * Cancel the scoped session's in-flight turn. * @returns completion; failures reject as in send. @@ -43,57 +42,11 @@ export interface IConversation { * @returns completion of the page pull. */ loadOlder(): Promise - /** - * Create runtime-only draft attachments and preview URLs. - * @param files - browser-owned image files. - * @param current - images already present in the composer. - * @returns ordered descriptors for the input state. - */ - createDraftImages( - files: readonly File[], - current?: readonly ComposerAttachment[], - ): readonly ComposerAttachment[] - /** - * Resolve ordered draft ids to runtime-owned attachments. - * @param ids - ordered composer attachment ids. - * @returns attachments still available in this browser runtime. - */ - draftImages(ids: readonly string[]): readonly ComposerAttachment[] - /** - * Release one draft attachment and its preview URL. - * @param id - draft-local attachment id. - */ - releaseDraftImage(id: string): void - /** - * Resolve a session-authorized historical image to an object URL. - * @param sessionId - session whose durable log grants the read. - * @param attachment - durable image reference from that log. - * @returns browser URL for inline and original-size rendering. - */ - resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise - /** - * Release every historical image URL owned by one rendered session. - * @param sessionId - session whose rendered image scope is ending. - */ - releaseSessionImages(sessionId: SessionId): void } -/** Opaque wrapper keeps browser `File` internals outside persisted store state. */ -class BrowserDraftAttachment implements ComposerAttachment { - readonly kind = 'image' as const - readonly id: string - readonly previewUrl: string - readonly #file: File - - constructor(file: File) { - this.id = crypto.randomUUID() - this.previewUrl = URL.createObjectURL(file) - this.#file = file - } - - get file(): File { - return this.#file - } +/** Create one browser-only draft descriptor; only its id enters input state. */ +function browserDraftAttachment(file: File): ComposerAttachment { + return { kind: 'image', id: crypto.randomUUID(), previewUrl: URL.createObjectURL(file), file } } interface ImageUrlEntry { @@ -106,7 +59,7 @@ interface ImageUrlEntry { export class ConversationService extends Service implements IConversation { /** The per-session input machine registry (InputService face, design §5.2). */ readonly input: InputService - private readonly draftAttachments = new Map() + private readonly draftAttachments = new Map() private readonly imageUrls = new Map() private readonly imageGenerations = new Map() private readonly createdImageUrls = new Set() @@ -135,11 +88,10 @@ export class ConversationService extends Service implements IConversation { * exists for caller choreography (the composer restores the draft on it). * @param text - prompt text, sent verbatim as one text block when non-empty. * @param mode - queue after the current turn, or steer into it. - * @param images - browser-owned temporary images promoted by the host during this call. */ - async send(text: string, mode: 'queue' | 'steer', images: readonly File[] = []): Promise { + async send(text: string, mode: 'queue' | 'steer'): Promise { const session = this.scopedSession('send') - await this.sendFiles(session, text, mode, images) + await this.sendFiles(session, text, mode, []) } /** @@ -190,7 +142,7 @@ export class ConversationService extends Service implements IConversation { ): readonly ComposerAttachment[] { this.validateImages(files, current) return files.map((file) => { - const attachment = new BrowserDraftAttachment(file) + const attachment = browserDraftAttachment(file) this.draftAttachments.set(attachment.id, attachment) this.createdImageUrls.add(attachment.previewUrl) return attachment @@ -330,19 +282,12 @@ export class ConversationService extends Service implements IConversation { current: readonly ComposerAttachment[], ): void { if (files.length === 0 && current.length === 0) return - // Deployment-wide limits only. Model capability is deliberately NOT - // checked here: the handshake's activeModel is the host default, not the - // session's current target (session.selectModel never refreshes it), so a - // client-side modality gate refuses sessions the host would accept and - // vice versa. The host preflight on session.prompt is the authority; its - // rejection renders through the composer error strip. + // Model capability is checked only by the host against the session's + // current target; the client owns deployment limits and the one-image UI. const description = this.requireSessions().hostDescription() const limits = description?.imageLimits const all = [...current.map(attachment => attachment.file), ...files] - if (limits !== undefined && all.length > limits.maxImagesPerMessage) { - throw new Error(`每条消息最多添加 ${limits.maxImagesPerMessage} 张图片`) - } - let totalBytes = 0 + if (all.length > 1) throw new Error('每条消息最多添加 1 张图片') for (const file of all) { const mediaType = imageMediaType(file.type) if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) { @@ -351,10 +296,6 @@ export class ConversationService extends Service implements IConversation { if (limits !== undefined && file.size > limits.maxImageBytes) { throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`) } - totalBytes += file.size - } - if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) { - throw new Error('图片总大小超过单条消息限制') } } diff --git a/packages/client/ui-conversation/tests/message-image.spec.tsx b/packages/client/ui-conversation/tests/message-image.spec.tsx index b6746ff2ed..c6f991e18d 100644 --- a/packages/client/ui-conversation/tests/message-image.spec.tsx +++ b/packages/client/ui-conversation/tests/message-image.spec.tsx @@ -48,14 +48,14 @@ describe('MessageImage', () => { Promise.resolve('blob:middle')} />, ) - const image = await view.findByAltText('middle') + const image = await view.findByAltText('history.png') const before = view.getByText('before') const after = view.getByText('after') expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index a73b119ac4..a9e1ce525a 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -29,6 +29,25 @@ async function bench() { } describe('ConversationService', () => { + it('keeps the browser draft to one image', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-one') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + try { + const [first] = b.root.createDraftImages([new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' })]) + if (first === undefined) throw new Error('draft attachment missing') + expect(() => b.root.createDraftImages( + [new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' })], + [first], + )).toThrow('每条消息最多添加 1 张图片') + expect(created).toHaveBeenCalledOnce() + } finally { + created.mockRestore() + revoked.mockRestore() + } + await b.runtime.dispose() + }) + it('routes operations through the public Session binding', async () => { const b = await bench() await b.scoped.send('hello', 'steer') diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index d6b55e5ba3..9abbe88420 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -644,7 +644,6 @@ function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock { case 'image': return { type: 'image', content: stringifySourceValue(block.attachment), - ...(block.alt !== undefined ? { imageAlt: block.alt } : {}), } case 'other': return sourceBlock(block.block) } diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 129f756aa6..5a4af19657 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -60,7 +60,7 @@ 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', 'conversation', 'sessions']) + expect(surface.inject).toEqual(['slots', 'conversation', 'sessionHistory']) }) it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => { @@ -72,10 +72,10 @@ describe('tsdown client artifact', () => { name: 'root', children: { 'conversation.view': { kind: 'list', scope: 'session' } }, }, (_p: { renderSlot?: unknown }) => null) - // The plugin injects 'conversation' as an ordering edge and 'sessions' + // The plugin injects 'conversation' as an ordering edge and 'sessionHistory' // for its per-session history callback; this bench supplies both. ctx.provide('conversation', {}) - ctx.provide('sessions', {}) + ctx.provide('sessionHistory', {}) 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/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index dc3b4bcf56..81dfe86af5 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -160,10 +160,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'attachments', summary: 'Immutable binary attachment service.', methods: [ - { - signature: 'abstract validateImage(input: SaveImageAttachment): void', - jsDoc: '/**\n * Validate one image against the deployment policy without persisting anything.\n * Callers persisting a multi-image batch validate every member first so a\n * malformed member cannot leave earlier members as unreferenced objects.\n * @param input - encoded bytes, declared media type, and optional display name.\n */', - }, { signature: 'abstract saveImage(input: SaveImageAttachment): Promise', jsDoc: '/**\n * Validate and durably commit one image before its owning session event is appended.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns a durable content-addressed reference.\n */', @@ -1859,7 +1855,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ImageBlock', - declaration: 'export interface ImageBlock {\n type: \'image\';\n attachment: ImageAttachmentRef;\n alt?: string;\n}', + declaration: 'export interface ImageBlock {\n type: \'image\';\n attachment: ImageAttachmentRef;\n}', }, { name: 'ImageMediaType', @@ -1919,7 +1915,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmModelInfo', - declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n inputModalities?: readonly ModelModality[];\n outputModalities?: readonly ModelModality[];\n}', + declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n inputModalities?: readonly ModelModality[];\n}', }, { name: 'LlmModelReasoningInfo', diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 4b5fb30e92..006eade076 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -41,7 +41,6 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-attachment-local": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 053f5abf24..c9eae83342 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -11,8 +11,8 @@ import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement, } from '@deepseek-ai/dsh-agent' -import { AttachmentError } from '@deepseek-ai/dsh-attachment-local' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment-local' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' @@ -79,37 +79,23 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten if (content.every(part => part.type === 'text')) { return content.map(part => ({ type: 'text', text: part.text })) } - const limits = ctx.attachments.imageLimits - const prepared = content.map(part => part.type === 'text' - ? part - : { part, data: decodeBase64(part.data) }) - const images = prepared.filter((part): part is Extract => 'data' in part) - if (images.length > limits.maxImagesPerMessage) { - throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') + if (content.filter(part => part.type === 'image').length > 1) { + throw new AttachmentError('A prompt may contain at most one image.', 'TOO_MANY_IMAGES') } - const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0) - if (totalBytes > limits.maxMessageImageBytes) { - throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') - } - // Validate the complete batch before persisting any member: the store has no - // garbage collection, so one malformed image must not leave the batch's - // valid members as published objects no message event will ever reference. - for (const image of images) { - ctx.attachments.validateImage({ - data: image.data, - mediaType: image.part.mediaType, - ...image.part.name === undefined ? {} : { name: image.part.name }, - }) - } - return Promise.all(prepared.map(async (item): Promise => { - if (!('data' in item)) return { type: 'text', text: item.text } + const durable: ContentBlock[] = [] + for (const part of content) { + if (part.type === 'text') { + durable.push({ type: 'text', text: part.text }) + continue + } const attachment = await ctx.attachments.saveImage({ - data: item.data, - mediaType: item.part.mediaType, - ...item.part.name === undefined ? {} : { name: item.part.name }, + data: decodeBase64(part.data), + mediaType: part.mediaType, + ...part.name === undefined ? {} : { name: part.name }, }) - return { type: 'image', attachment, ...item.part.alt === undefined ? {} : { alt: item.part.alt } } - })) + durable.push({ type: 'image', attachment }) + } + return durable } /** @@ -1222,8 +1208,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const target = targetFor(agent).current const provider = target.provider const model = target.model - const activeModel = await ctx.llm.resolveModelInfo(provider, model) - if (activeModel.inputModalities !== undefined && !activeModel.inputModalities.includes('image')) { + const modelInfo = await ctx.llm.resolveModelInfo(provider, model) + if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) { return err(request, { code: 'attachment-error', message: `Model "${model}" does not support image input.`, @@ -1419,24 +1405,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, host: { - async describe(request) { - const activeModel = (await ctx.llm.listModels(defaults.provider)) - .find(model => model.id === defaults.model) + describe(request) { // TODO(step2): version should read apps/cli's package.json; placeholder for now. - return ok(request, { + return Promise.resolve(ok(request, { version: '0.0.1', // Same source as session.create's fallback: the UI's default project // must match where an unspecified-cwd session actually lands. cwd: defaults.cwd, provider: defaults.provider, model: defaults.model, - ...activeModel === undefined ? {} : { activeModel }, imageLimits: { ...ctx.attachments.imageLimits, mediaTypes: [...ctx.attachments.imageLimits.mediaTypes], }, attachedSessions: ctx.agents.list().length, - }) + })) }, async pickDirectory(request, signal) { diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index e9120c9b44..e4ba100f17 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -3,15 +3,11 @@ */ import { z } from 'zod' -import type { ModelModality } from '@deepseek-ai/dsh-llm' import type { DirectoryEntry } from './host.ts' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import { imageMediaTypeSchema } from './sessions.schema.ts' -/** Merge-extensible modality passthrough: declaration merging cannot extend a runtime Zod union. */ -const modalitySchema = z.string() as unknown as z.ZodType - /** host.describe request payload (empty object literal). */ export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType>> @@ -21,18 +17,8 @@ export const hostDescribeValueSchema = z.object({ cwd: z.string(), provider: z.string().optional(), model: z.string().optional(), - activeModel: z.object({ - provider: z.string(), - id: z.string(), - name: z.string(), - description: z.string().optional(), - inputModalities: z.array(modalitySchema).optional(), - outputModalities: z.array(modalitySchema).optional(), - }).optional(), imageLimits: z.object({ maxImageBytes: z.number().int().positive(), - maxImagesPerMessage: z.number().int().positive(), - maxMessageImageBytes: z.number().int().positive(), maxImagePixels: z.number().int().positive(), mediaTypes: z.array(imageMediaTypeSchema), }).optional(), diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 1b53d6caa8..46e9c44c88 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -5,7 +5,6 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' -import type { LlmModelInfo } from '@deepseek-ai/dsh-llm/types' /** One directory row of a listing: a child entry or a breadcrumb ancestor. */ export interface DirectoryEntry { @@ -49,8 +48,6 @@ export interface HostApi { cwd: string provider?: string model?: string - /** Catalog entry for the active route; absent means its capabilities are unknown. */ - activeModel?: LlmModelInfo /** Resolved authoritative image-upload limits. */ imageLimits?: ImageAttachmentLimits attachedSessions: number diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index d501933615..c7cd2d02e0 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -210,7 +210,7 @@ export const imageMediaTypeSchema = z.union([ /** Prompt wire content is intentionally narrower than merge-extensible durable core content. */ export const promptContentPartSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('text'), text: z.string() }), - z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional(), alt: z.string().optional() }), + z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional() }), ]) /** session.prompt request payload. */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 5579a89f94..ead239d247 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -162,7 +162,7 @@ export interface SessionSummary { /** Browser-submitted prompt content; image bytes are promoted to durable references by the host. */ export type PromptContentPart = | { type: 'text'; text: string } - | { type: 'image'; mediaType: ImageMediaType; data: string; name?: string; alt?: string } + | { type: 'image'; mediaType: ImageMediaType; data: string; name?: string } /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ export interface SessionsApi { diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index db9d17b5b2..d265759da7 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -118,6 +118,26 @@ function expectValue(response: { result: { ok: true; value: T } | { ok: false } describe('Web session model selection', () => { + it('rejects a second prompt image before attachment persistence', async () => { + const { ctx, sessionId } = await harness() + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const image = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AA==' } + const response = await api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [image, image], + })) + expect(response.result).toEqual({ + ok: false, + error: { + code: 'attachment-error', + message: 'A prompt may contain at most one image.', + details: { reason: 'TOO_MANY_IMAGES' }, + }, + }) + await ctx.fiber.dispose() + }) + it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => { const { ctx, sessionId } = await harness({ provider: 'deepseek', diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 80a46af8cf..4295fe5b76 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -264,13 +264,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { hostDescription: { version: 'v', cwd: '/w', - activeModel: { - provider: 'future', - id: 'audio-model', - name: 'Audio Model', - inputModalities: ['text', 'audio'], - outputModalities: ['audio'], - }, attachedSessions: 0, }, })) @@ -281,13 +274,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { value: { version: 'v', cwd: '/w', - activeModel: { - provider: 'future', - id: 'audio-model', - name: 'Audio Model', - inputModalities: ['text', 'audio'], - outputModalities: ['audio'], - }, attachedSessions: 0, }, }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index da3d9a89e5..ea6a25a6b0 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -249,25 +249,10 @@ describe('host domain schemas', () => { cwd: '/x', provider: 'p', model: 'm', - activeModel: { - provider: 'p', - id: 'm', - name: 'Model', - inputModalities: ['text', 'audio'], - outputModalities: ['text', 'audio'], - }, attachedSessions: 2, }) expect(value.attachedSessions).toBe(2) - expect(value.activeModel?.inputModalities).toEqual(['text', 'audio']) - expect(value.activeModel?.outputModalities).toEqual(['text', 'audio']) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined() - expect(() => hostDescribeValueSchema.parse({ - version: '1', - cwd: '/x', - activeModel: { provider: 'p', id: 'm', name: 'Model', inputModalities: [{ type: 'audio' }] }, - attachedSessions: 0, - })).toThrow() }) it('validates the browse listing/creation payloads', () => { diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 6f32d1bbc6..d4da9a96a1 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../../attachment/attachment" }, - { - "path": "../../attachment/attachment-local" - }, { "path": "../../llm/llm" }, diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 8338becd7f..308da6dcb9 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -74,7 +74,6 @@ function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo name: model.name ?? model.id, ...model.description === undefined ? {} : { description: model.description }, inputModalities: ['text'], - outputModalities: ['text'], } } @@ -171,7 +170,7 @@ export class DeepSeekAdapter extends LlmAdapter { // capability — "unknown" here would let the host accept and persist // images the serializer must then reject. ...configured === undefined - ? { provider, id: model, name: model, inputModalities: ['text' as const], outputModalities: ['text' as const] } + ? { provider, id: model, name: model, inputModalities: ['text' as const] } : modelInfo(provider, configured), ...contextWindow === undefined ? {} : { context: { contextWindow } }, ...this.options.defaults?.thinking === 'disabled' diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index dcf095bc2a..0dc969c17f 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -658,8 +658,8 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'], outputModalities: ['text'] }, - { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'], outputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] }, ]) await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .resolves.toMatchObject({ @@ -762,8 +762,8 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmService) LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'], outputModalities: ['text'] }, - { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'], outputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] }, ]) }) @@ -784,8 +784,8 @@ describe('plugin registration and config', () => { ], }) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'private-fast', name: 'private-fast', inputModalities: ['text'], outputModalities: ['text'] }, - { provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'], outputModalities: ['text'] }, + { provider: 'deepseek', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] }, + { provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'] }, ]) await expect(ctx.llm.resolveModelInfo('deepseek', 'private-fast')) .resolves.toMatchObject({ context: { contextWindow: 32_000 } }) diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 6f8a442e2b..2a4b08e607 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -130,7 +130,6 @@ export class PiAiAdapter extends LlmAdapter { id: model.id, name: model.name, inputModalities: [...model.input], - outputModalities: ['text'], }))) } @@ -155,7 +154,6 @@ export class PiAiAdapter extends LlmAdapter { id: model, name: resolvedModel.name, inputModalities: [...resolvedModel.input], - outputModalities: ['text'], context: { contextWindow: resolvedModel.contextWindow }, reasoning: { efforts: levels.map(level => ({ diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 6ffccfa0e1..1f51486584 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -250,16 +250,10 @@ describe('PiAiAdapter provider routing', () => { class LateAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { maxImageBytes: 1, - maxImagesPerMessage: 1, - maxMessageImageBytes: 1, maxImagePixels: 1, mediaTypes: ['image/png'], } - validateImage(_input: SaveImageAttachment): void { - throw new Error('not used') - } - saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('not used')) } @@ -440,7 +434,7 @@ describe('provider profile lifecycle', () => { const models = await ctx.llm.listModels('openai') expect(models.find(model => model.id === 'gpt-4.1')).toEqual({ provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1', - inputModalities: ['text', 'image'], outputModalities: ['text'], + inputModalities: ['text', 'image'], }) expect(models.every(model => model.provider === 'openai')).toBe(true) const info = await ctx.llm.resolveModelInfo('openai', 'gpt-4.1') diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 0d1bdfadc5..db5b28bf9f 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -68,16 +68,10 @@ async function harness(image?: StoredImageAttachment): Promise { class E2eAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { maxImageBytes: fixture.data.byteLength, - maxImagesPerMessage: 1, - maxMessageImageBytes: fixture.data.byteLength, maxImagePixels: fixture.ref.width * fixture.ref.height, mediaTypes: [fixture.ref.mediaType], } - validateImage(_input: SaveImageAttachment): void { - throw new Error('e2e attachment fixture is read-only') - } - saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('e2e attachment fixture is read-only')) } @@ -228,7 +222,7 @@ for (const profile of providerCases) { type: 'text', text: 'What type of machine-readable symbol is shown in the attached image? Reply with exactly: QR code', }, - { type: 'image', attachment: ref, alt: 'machine-readable symbol' }, + { type: 'image', attachment: ref }, ], source: { kind: 'plugin', plugin: 'test' }, })], diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 72747bbe5e..e617d3aa2d 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/llm/llm/README.md -README.md: ecd6da304a894a687153608f5b468f867ad32e6b -README.zh.md: c43da45bf0c573a78c194d731bff1d9765b1eed6 +README.md: f6bf6ed88cc3aa21c57b5a08249848bdfff2f0c1 +README.zh.md: e466568de5a57b36dcc9fb0bd876ec2216a6c5f3 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index ecd6da304a..f6bf6ed88c 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -23,7 +23,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. -Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity or modality metadata fails with `INVALID_MODEL_INFO`, and invalid context or reasoning metadata with `INVALID_MODEL_CONTEXT` or `INVALID_MODEL_REASONING`. +Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity metadata fails with `INVALID_MODEL_INFO`, and invalid context or reasoning metadata with `INVALID_MODEL_CONTEXT` or `INVALID_MODEL_REASONING`. Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index c43da45bf0..e466568de5 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -23,7 +23,7 @@ 提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 -确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份或模态元数据会以 `INVALID_MODEL_INFO` 失败,无效的上下文或推理元数据则以 `INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。 +确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份元数据会以 `INVALID_MODEL_INFO` 失败,无效的上下文或推理元数据则以 `INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。 推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速结束。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 8fba36e3eb..d024db3c85 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -254,26 +254,9 @@ export class LlmService extends Service { return this.registration(provider).retryPolicy } - /** - * Validate adapter-owned modality arrays and detach them. One rule for the - * advisory catalog and exact resolution: both validate, both copy — two - * readings of the same adapter field with different trust or detachment - * would be an unexplained asymmetry. - * @param provider - provider route (diagnostic context). - * @param code - error code matching the calling surface. - * @param modalities - adapter-owned array, or undefined for unknown. - * @returns a detached copy, or undefined when absent. - */ - private detachedModalities( - provider: string, - code: 'INVALID_CATALOG' | 'INVALID_MODEL_INFO', - modalities: readonly unknown[] | undefined, - ): ModelModality[] | undefined { - if (modalities === undefined) return undefined - if (!Array.isArray(modalities) || modalities.some(entry => typeof entry !== 'string')) { - throw new LlmError(`adapter returned invalid modality metadata for provider "${provider}"`, code) - } - return [...(modalities as readonly ModelModality[])] + /** Detach typed adapter-owned modality metadata. */ + private detachedModalities(modalities: readonly ModelModality[] | undefined): ModelModality[] | undefined { + return modalities === undefined ? undefined : [...modalities] } /** @@ -300,15 +283,13 @@ export class LlmService extends Service { throw new LlmError(`adapter returned invalid or duplicate model metadata for provider "${provider}"`, 'INVALID_CATALOG') } seen.add(model.id) - const inputModalities = this.detachedModalities(provider, 'INVALID_CATALOG', model.inputModalities) - const outputModalities = this.detachedModalities(provider, 'INVALID_CATALOG', model.outputModalities) + const inputModalities = this.detachedModalities(model.inputModalities) return { provider: model.provider, id: model.id, name: model.name, ...model.description === undefined ? {} : { description: model.description }, ...inputModalities === undefined ? {} : { inputModalities }, - ...outputModalities === undefined ? {} : { outputModalities }, } }) } @@ -360,15 +341,13 @@ export class LlmService extends Service { } // Capability metadata rides through: an explicit modality omission is // negative capability downstream preflights act on (image admission). - const inputModalities = this.detachedModalities(provider, 'INVALID_MODEL_INFO', resolved.inputModalities) - const outputModalities = this.detachedModalities(provider, 'INVALID_MODEL_INFO', resolved.outputModalities) + const inputModalities = this.detachedModalities(resolved.inputModalities) const info: LlmResolvedModelInfo = { provider, id: model, name: resolved.name, ...resolved.description === undefined ? {} : { description: resolved.description }, ...inputModalities === undefined ? {} : { inputModalities }, - ...outputModalities === undefined ? {} : { outputModalities }, ...context === undefined ? {} : { context: { contextWindow: context.contextWindow } }, } const reasoning = resolved.reasoning diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index ba03b51a8a..3d61f5b8a3 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -57,8 +57,6 @@ export interface ImageBlock { type: 'image' /** Immutable bytes and intrinsic display metadata owned by the attachment service. */ attachment: ImageAttachmentRef - /** Optional provider- and UI-facing alternative text, carried from the prompt wire's image part. */ - alt?: string } /** A tool invocation requested by the model. */ @@ -156,8 +154,6 @@ export interface LlmModelInfo { description?: string /** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */ inputModalities?: readonly ModelModality[] - /** Structured response modalities; absent means unknown, while an explicit omission is negative capability. */ - outputModalities?: readonly ModelModality[] } /** Provider-owned context capacity for one exact provider/model route. */ diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index e0d173599d..98ccb1befb 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -857,8 +857,6 @@ describe('LlmService', () => { [{ provider: 'route', id: 'model', name: 1 }, 'non-string name'], [{ provider: 'route', id: 'model', name: '' }, 'empty name'], [{ provider: 'route', id: 'model', name: 'Model', description: 1 }, 'non-string description'], - [{ provider: 'route', id: 'model', name: 'Model', inputModalities: 'text' }, 'non-array input modalities'], - [{ provider: 'route', id: 'model', name: 'Model', outputModalities: [1] }, 'non-string output modality'], ] as const)('rejects invalid exact model metadata (%s: %s)', async (metadata, _label) => { const ctx = new Context() await ctx.plugin(LlmService) @@ -880,7 +878,7 @@ describe('LlmService', () => { override resolveModel(): Promise { return Promise.resolve({ provider: 'route', id: 'model', name: 'Model', - inputModalities: ['text', 'image'], outputModalities: ['text'], + inputModalities: ['text', 'image'], }) } }(SCRIPT) @@ -890,7 +888,7 @@ describe('LlmService', () => { // rebuild that drops it silently reads as "modalities unknown". await expect(ctx.llm.resolveModelInfo('route', 'model')).resolves.toEqual({ provider: 'route', id: 'model', name: 'Model', - inputModalities: ['text', 'image'], outputModalities: ['text'], + inputModalities: ['text', 'image'], }) }) @@ -1177,8 +1175,6 @@ describe('LlmService', () => { [{ provider: 'route', id: 'm', name: 1 }, 'non-string name'], [{ provider: 'route', id: 'm', name: '' }, 'empty name'], [{ provider: 'route', id: 'm', name: 'M', description: 1 }, 'non-string description'], - [{ provider: 'route', id: 'm', name: 'M', inputModalities: 'text' }, 'non-array input modalities'], - [{ provider: 'route', id: 'm', name: 'M', outputModalities: [1] }, 'non-string output modality'], ] as const)('rejects invalid model metadata (%s: %s)', async (metadata, _label) => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index ec2698393e..533ebd2453 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -25,11 +25,6 @@ const CHARS_PER_TOKEN = 4 /** Per-block structural overhead for JSON framing and type tags. */ const BLOCK_OVERHEAD = 4 -/** Provider-neutral visual estimate: base cost plus one cost unit per 512px tile. */ -const IMAGE_BASE_TOKENS = 85 -const IMAGE_TILE_TOKENS = 170 -const IMAGE_TILE_EDGE = 512 - /** Role-field framing overhead added to every priced message. */ const ROLE_OVERHEAD = 4 @@ -362,12 +357,6 @@ export class TokenMeterService extends Service { case 'reasoning': tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD break - case 'image': { - const tiles = Math.ceil(block.attachment.width / IMAGE_TILE_EDGE) - * Math.ceil(block.attachment.height / IMAGE_TILE_EDGE) - tokens += IMAGE_BASE_TOKENS + tiles * IMAGE_TILE_TOKENS + BLOCK_OVERHEAD - break - } case 'tool-call': tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN) + Math.ceil(block.arguments.length / CHARS_PER_TOKEN) diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 4ad886f419..e658fe2981 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -1,6 +1,5 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { createUserMessage, CallId, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' @@ -129,16 +128,6 @@ describe('TokenMeterService pricing', () => { const blocks: ContentBlock[] = [ { type: 'text', text: 'abcd' }, { type: 'reasoning', text: 'ab' }, - { - type: 'image', - attachment: { - attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), - mediaType: 'image/png', - bytes: 1, - width: 1024, - height: 513, - }, - }, { type: 'tool-call', id: CallId('c'), name: 'read', arguments: '{"x":1}' }, { type: 'tool-result', @@ -152,7 +141,7 @@ describe('TokenMeterService pricing', () => { role: 'assistant', content: blocks, source: { kind: 'plugin', plugin: 'test' }, })) - expect(estimated).toBe(813) + expect(estimated).toBeGreaterThan(30) expect(service.estimateMessage(textMessage('abcd'))).toBe(9) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc2e7da8af..a57fec0692 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2965,9 +2965,6 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment - '@deepseek-ai/dsh-attachment-local': - specifier: workspace:^ - version: link:../../attachment/attachment-local '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index c843f94a5a..d388f31241 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -90,16 +90,10 @@ const ATTACHMENT_COMPANION = '../packages/attachment/attachment-local/src/invari class TestAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { maxImageBytes: 1, - maxImagesPerMessage: 1, - maxMessageImageBytes: 1, maxImagePixels: 1, mediaTypes: ['image/png'], } - validateImage(_input: SaveImageAttachment): void { - throw new Error('test invariant attachment store does not validate images') - } - saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('test invariant attachment store does not save images')) } From 8165518561cc44cc053f7e0b815e9da09888c94a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:32:46 +0800 Subject: [PATCH 016/229] fix(web): query the composer by the zh default placeholder in image-display snapshot --- apps/web/tests/image-display.snapshot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 40501dced1..5e5aa1c3dd 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -167,7 +167,7 @@ it('accepts a pasted image into the composer rail and removes it', async () => { // Image-only send arming is pinned at package level (input-bar.spec.tsx); // this assembled lane pins the intake chain over the built graph. - const textarea = await screen.findByPlaceholderText('Message the agent', {}, { timeout: 10_000 }) + const textarea = await screen.findByPlaceholderText('给智能体发消息', {}, { timeout: 10_000 }) const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' }) fireEvent.paste(textarea, { clipboardData: { From 3733bd1374f33e6abd8603a4a420c5139da85dd1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 00:30:58 +0800 Subject: [PATCH 017/229] fix(gui): remove temporary multimodal routing state --- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 6 +- ...-image-input-and-durable-attachments.zh.md | 6 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/app-cli-entry.ts | 117 ++---------------- apps/cli/src/args.ts | 9 -- apps/cli/src/bin.ts | 2 - apps/cli/src/web.ts | 6 - apps/cli/tests/args.spec.ts | 5 +- apps/cli/tests/llm-route.spec.ts | 76 ------------ packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../connection/src/client/connection.ts | 5 +- .../client/connection/src/client/fixture.ts | 14 --- .../connection/tests/connection.spec.ts | 3 - .../runtime/src/client/contract/sessions.ts | 7 +- packages/client/runtime/src/client/index.ts | 1 - .../runtime/src/client/sessions/service.ts | 19 +-- .../client/runtime/tests/client-apply.spec.ts | 6 - packages/client/test-runtime/src/sessions.ts | 9 -- .../test-runtime/tests/runtime.spec.tsx | 2 - .../ui-conversation/src/client/apply.ts | 4 +- .../src/client/contract/slots.ts | 2 +- .../ui-conversation/src/client/service.ts | 49 +------- .../src/client/skeleton/InputBar.tsx | 4 +- .../ui-conversation/tests/input-bar.spec.tsx | 8 +- .../tests/service-orchestration.spec.ts | 25 ++++ packages/host/apiproxy/src/api-proxy.ts | 13 +- packages/host/apiproxy/src/api/host.schema.ts | 20 --- packages/host/apiproxy/src/api/host.ts | 6 - .../host/apiproxy/tests/fetch-carrier.spec.ts | 34 ----- .../host/apiproxy/tests/rpc-schemas.spec.ts | 17 +-- 35 files changed, 69 insertions(+), 426 deletions(-) delete mode 100644 apps/cli/tests/llm-route.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 2428e493fa..49c35959ea 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 7c14a8bdbb6d8164b2a6eb4432d196936271e4d1 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: e9d7f8ebe78aa76285367e83374a6ace3a9eef21 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: dff89f1ab5124eaa96e5d95214911a82be022d16 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: c518edd5c15ebd2a6b776a899049870a4274b5d7 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 7c14a8bdbb..dff89f1ab5 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -120,9 +120,9 @@ Base64 crosses JSON-RPC once and is discarded after persistence. The host valida Model catalog entries gain optional merge-extensible input and output modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. -The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. `host.describe` projects the host-default active model and the image limits into `SessionsService`; the composer applies only the deployment limits before allocating object URLs or base64. Model capability is deliberately not gated client-side: the handshake snapshot cannot represent a session's current target after `session.selectModel`, so the host preflight is the sole capability authority and its rejection renders through the composer error strip. Decoded-pixel validation and the session's actual route remain authoritative on the host. +The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. -The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, then resolves each durable reference and emits native image content only for models that declare image input. The shipped Web assembly reaches it through `dsh web --provider --model `: the yml pi-ai row already routes openai/anthropic with ambient credentials, and only a catalog provider absent from that row is mounted dynamically; the DeepSeek-only default remains text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. +The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, then resolves each durable reference and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. @@ -138,7 +138,7 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state ### Limits and trust boundaries -Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and are projected to the client for fast-path guidance; host validation remains authoritative. The client connection carrier independently caps buffered API request bodies, deriving the cap from the aggregate image limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. +Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier independently caps buffered API request bodies, deriving the cap from the host attachment service's aggregate image limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. Malformed base64, unsupported or mismatched media, truncated headers, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index e9d7f8ebe7..c518edd5c1 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -120,9 +120,9 @@ Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规 模型目录项增加可选且可合并扩展的输入与输出模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。`host.describe` 把宿主默认的当前模型和图片限制投影到 `SessionsService`;composer 在分配对象 URL 或 base64 前只应用部署级限制。模型能力刻意不在客户端把关:握手快照无法表达 `session.selectModel` 之后会话的当前目标,因此宿主前置检查是唯一的能力权威,其拒绝通过 composer 错误条呈现。解码像素校验与会话的实际路由仍由宿主作出权威判定。 +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 -Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的 Web 组装通过 `dsh web --provider --model ` 到达这条路径:yml 的 pi-ai row 已用环境凭据路由 openai/anthropic,只有该 row 之外的目录 provider 才会动态挂载;仅含 DeepSeek 的默认组装仍是纯文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 +Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 @@ -138,7 +138,7 @@ token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为 ### 限制与信任边界 -第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引;宿主校验仍是权威结果。客户端连接载体会独立限制 API 请求体的缓冲大小,并根据图片总量限制加上 base64 和请求封装的膨胀量推导上限;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体会独立限制 API 请求体的缓冲大小,并根据宿主附件服务的图片总量限制,加上 base64 和请求封装的膨胀量推导上限;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 格式错误的 base64、不支持或不匹配的媒体、截断的文件头、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index b9f1b0d40c..bb5f370009 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 337370f6b3738cef22bc826501633b9aca1f8c62 -README.zh.md: e1bdad1745aed4f9f6cd08d9e9c43bcadcec7711 +README.md: 93c36d18abd06bbd7a80c918f520b92489180395 +README.zh.md: 85f4624a592eaf2ae44dc31fb4e18fb5657e62fd diff --git a/apps/cli/README.md b/apps/cli/README.md index 337370f6b3..93c36d18ab 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. `dsh web --provider --model ` selects that route: for the shipped roster (openai/anthropic) the already-mounted yml pi-ai row serves it with provider-native ambient credentials, while a pi-ai catalog provider absent from that row is mounted dynamically; the default DeepSeek route remains text-only. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index e1bdad1745..85f4624a59 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。`dsh web --provider --model ` 选择对应路由:出货清单内的 provider(openai/anthropic)由 yml 中已挂载的 pi-ai row 以提供方原生环境凭据直接服务,只有该 row 之外的 pi-ai catalog provider 才会动态挂载;默认 DeepSeek 路由仍仅支持文本。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index bfea7f3662..a030522c5a 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -61,89 +61,6 @@ export function resolveLanTrust( return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } } -/** One provider/model source layer for {@link resolveLlmRoute}, in override order. */ -export interface LlmRouteInput { - /** CLI flag values (highest precedence). */ - cli: { provider?: string | undefined; model?: string | undefined } - /** Profile-json values (parsed JSON — validated here, the config boundary). */ - profile: { provider?: unknown; model?: unknown } - /** The api-gateway yml row's config values (deployment defaults). */ - gateway: { provider?: unknown; model?: unknown } - /** Providers the shipped yml already routes through its static pi-ai row. */ - ymlPiAiProviders: readonly string[] -} - -/** The boot's resolved LLM routing decision. */ -export interface LlmRoute { - /** Effective api-gateway provider. */ - provider: string - /** Pi-ai provider to mount dynamically; undefined when DeepSeek or a yml-routed provider serves the request. */ - dynamicPiAiProvider: string | undefined -} - -/** - * Resolve the boot's LLM route from the layered provider/model sources. - * A non-DeepSeek provider requires a model set at least as explicitly as the - * provider itself (flag/profile) — origin decides, never a comparison against - * any deployment's default model value, so editing the yml default cannot - * silently disarm the guard. Providers the shipped yml pi-ai row already - * routes are NOT mounted again: `LlmService.registerAdapter` rejects - * duplicate routes, so the gateway provider/model patch alone selects them. - * @param input - the layered provider/model sources and the yml pi-ai roster. - * @returns the effective provider and the dynamic pi-ai mount decision. - */ -export function resolveLlmRoute(input: LlmRouteInput): LlmRoute { - const provider = input.cli.provider ?? input.profile.provider ?? input.gateway.provider - if (typeof provider !== 'string' || provider === '') { - throw new Error('dsh: api-gateway provider must be a non-empty string') - } - if (provider !== 'deepseek') { - const providerFromYml = input.cli.provider === undefined && input.profile.provider === undefined - // A yml-set provider trusts its own row pairing; an override must bring - // its model along instead of inheriting the yml default's. - const model = providerFromYml - ? input.gateway.model - : input.cli.model ?? input.profile.model - if (typeof model !== 'string' || model === '') { - throw new Error(`dsh: provider ${provider} requires an explicit model`) - } - } - return { - provider, - dynamicPiAiProvider: provider === 'deepseek' || input.ymlPiAiProviders.includes(provider) - ? undefined - : provider, - } -} - -/** - * Bypass parse of an include yml's top-level entry rows (id → row). Exported - * so tests can pin the shipped tree's real row coupling instead of literals. - * @param configPath - absolute path of the include cordis.yml. - * @returns row map keyed by entry id. - */ -export function parseIncludeYmlRows(configPath: string): Map { - const doc = yaml.load(readFileSync(configPath, 'utf8'), { schema: includeYamlSchema }) - if (!Array.isArray(doc)) throw new Error(`dsh: ${configPath} is not a top-level entry list`) - const rows = new Map() - for (const row of doc as { id?: string; config?: unknown }[]) { - if (typeof row.id === 'string') rows.set(row.id, row) - } - return rows -} - -/** - * Providers the yml's static pi-ai row routes — the roster {@link resolveLlmRoute} reuses. - * @param rows - parsed include rows. - * @returns provider ids in row order (empty when the row is absent). - */ -export function ymlPiAiProvidersOf(rows: ReadonlyMap): string[] { - const config = rows.get('llm-pi-ai')?.config as { providers?: { provider?: unknown }[] } | undefined - return (config?.providers ?? []) - .map(entry => entry.provider) - .filter((value): value is string => typeof value === 'string') -} - /** One profile-json key mapped onto a yml row's config field. */ interface ProfileMapping { jsonPath: string @@ -198,14 +115,6 @@ export interface AppCLIEntryOptions { port?: number /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */ workspaceRoot?: string - /** - * Host default provider override. Providers the shipped yml pi-ai row - * already routes are reused; only a provider absent from that row mounts - * pi-ai dynamically. - */ - provider?: string - /** Host default model override. */ - model?: string /** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */ trustedHosts?: string[] } @@ -229,7 +138,6 @@ export class AppCLIEntry { lanAddresses: readonly string[] = [] private patches: PatchOptions[] = [] - private piAiProvider: string | undefined constructor(private readonly options: AppCLIEntryOptions) {} @@ -290,17 +198,6 @@ export class AppCLIEntry { if (this.options.host !== undefined) put('webserver', 'host', this.options.host) if (this.options.port !== undefined) put('webserver', 'port', this.options.port) if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot) - if (this.options.provider !== undefined) put('api-gateway', 'provider', this.options.provider) - if (this.options.model !== undefined) put('api-gateway', 'model', this.options.model) - - const gatewayConfig = rows.get('api-gateway')?.config as Record | undefined - const route = resolveLlmRoute({ - cli: { provider: this.options.provider, model: this.options.model }, - profile: { provider: profile.provider, model: profile.model }, - gateway: { provider: gatewayConfig?.provider, model: gatewayConfig?.model }, - ymlPiAiProviders: ymlPiAiProvidersOf(rows), - }) - this.piAiProvider = route.dynamicPiAiProvider // Source 2b: authorities for the /api browser-trust fence (rationale on // resolveLanTrust). @@ -333,12 +230,6 @@ export class AppCLIEntry { ...this.patches.length > 0 ? { patches: this.patches } : {}, }, }) - if (this.piAiProvider !== undefined) { - await ctx.loader.create({ - name: '@deepseek-ai/dsh-llm-pi-ai', - config: { providers: [{ provider: this.piAiProvider }] }, - }) - } if (this.options.dev) { await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) } @@ -373,7 +264,13 @@ export class AppCLIEntry { /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */ private parseYmlRows(): Map { - return parseIncludeYmlRows(this.options.configPath) + const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema }) + if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`) + const rows = new Map() + for (const row of doc as { id?: string; config?: unknown }[]) { + if (typeof row.id === 'string') rows.set(row.id, row) + } + return rows } /** Profile json under cwd; read-only — never created here, absent = no user config. */ diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index b51d2f5fdc..b929dc73f2 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -33,7 +33,6 @@ interface HeadlessInvocation { * value fails loud at boot). `port` is `Number`-coerced only because the schema * wants a number, not a string. `dev` mounts the client HMR driver; * `workspaceRoot` is the parent directory for name-created workspaces. - * `provider` and `model` override the host's default model route. */ interface WebInvocation { mode: 'web' @@ -41,8 +40,6 @@ interface WebInvocation { port?: number dev: boolean workspaceRoot?: string - provider?: string - model?: string /** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */ trustedHosts?: string[] } @@ -56,8 +53,6 @@ interface WebOptions { port?: string dev?: boolean workspaceRoot?: string - provider?: string - model?: string trustedHost?: string[] } @@ -74,8 +69,6 @@ function resolveWeb(options: WebOptions): WebInvocation { ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, - ...options.provider !== undefined && { provider: options.provider }, - ...options.model !== undefined && { model: options.model }, ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, } } @@ -128,8 +121,6 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') .option('--workspace-root ', 'parent directory for name-created workspaces') - .option('--provider ', 'override the host default provider') - .option('--model ', 'override the host default model') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .action((options: WebOptions) => { // Commander parses the parent (default-surface) options on either side of diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 84b8d066ff..37086cf0d3 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -35,8 +35,6 @@ switch (invocation.mode) { invocation.port, invocation.dev, invocation.workspaceRoot, - invocation.provider, - invocation.model, invocation.trustedHosts, ) break diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index f6dd69de97..69e79ab5d9 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -22,8 +22,6 @@ const LOOPBACK_HOST = '127.0.0.1' * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default. * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. - * @param provider - provider override, or `undefined` to keep the profile/config route. - * @param model - model override, or `undefined` to keep the profile/config route. * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. */ export async function runWeb( @@ -31,8 +29,6 @@ export async function runWeb( port: number | undefined, dev: boolean, workspaceRoot: string | undefined, - provider: string | undefined, - model: string | undefined, trustedHosts: string[] | undefined, ): Promise { const entry = new AppCLIEntry({ @@ -41,8 +37,6 @@ export async function runWeb( ...host !== undefined && { host }, ...port !== undefined && { port }, ...workspaceRoot !== undefined && { workspaceRoot }, - ...provider !== undefined && { provider }, - ...model !== undefined && { model }, ...trustedHosts !== undefined && { trustedHosts }, }) const { ctx, port: boundPort } = await entry.run() diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index d1d64ad643..29232b94dd 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -35,15 +35,12 @@ describe('parseDshArgs', () => { // at boot); the adapter only coerces the port string to a number. expect(parse([ 'web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w', - '--provider', 'anthropic', '--model', 'claude-opus-4-8', ])).toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w', - provider: 'anthropic', - model: 'claude-opus-4-8', }) // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) @@ -65,6 +62,8 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '-p', 'task'])).toBe(1) expect(exitCode(['web', '--resume', 's'])).toBe(1) expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) + expect(exitCode(['web', '--provider', 'anthropic'])).toBe(1) + expect(exitCode(['web', '--model', 'claude-opus-4-8'])).toBe(1) }) it('exits 0 for --help (disclosing web) and --version', () => { diff --git a/apps/cli/tests/llm-route.spec.ts b/apps/cli/tests/llm-route.spec.ts deleted file mode 100644 index 58edb47973..0000000000 --- a/apps/cli/tests/llm-route.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** resolveLlmRoute: layered provider/model resolution and the dynamic pi-ai mount decision. */ -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { parseIncludeYmlRows, resolveLlmRoute, ymlPiAiProvidersOf } from '../src/app-cli-entry.ts' - -/** The shipped yml shape: DeepSeek gateway default plus a pi-ai row routing openai/anthropic. */ -const SHIPPED = { - gateway: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - ymlPiAiProviders: ['openai', 'anthropic'], -} - -describe('resolveLlmRoute', () => { - it('keeps the DeepSeek default without any dynamic mount', () => { - expect(resolveLlmRoute({ cli: {}, profile: {}, ...SHIPPED })) - .toEqual({ provider: 'deepseek', dynamicPiAiProvider: undefined }) - }) - - it('reuses the yml pi-ai row for providers it already routes (no duplicate adapter)', () => { - expect(resolveLlmRoute({ - cli: { provider: 'anthropic', model: 'claude-opus-4-8' }, profile: {}, ...SHIPPED, - })).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined }) - }) - - it('mounts pi-ai dynamically only for providers absent from the yml row', () => { - expect(resolveLlmRoute({ - cli: { provider: 'google', model: 'gemini-3-pro' }, profile: {}, ...SHIPPED, - })).toEqual({ provider: 'google', dynamicPiAiProvider: 'google' }) - }) - - it('requires an explicit model wherever the provider override came from, by origin', () => { - // CLI provider with no CLI/profile model: the yml DeepSeek default must not leak in. - expect(() => resolveLlmRoute({ cli: { provider: 'anthropic' }, profile: {}, ...SHIPPED })) - .toThrow(/provider anthropic requires an explicit model/) - // Profile provider paired with a profile model is explicit enough. - expect(resolveLlmRoute({ - cli: {}, profile: { provider: 'openai', model: 'gpt-5' }, ...SHIPPED, - })).toEqual({ provider: 'openai', dynamicPiAiProvider: undefined }) - // Profile provider with only the yml default model: same gap, same refusal. - expect(() => resolveLlmRoute({ cli: {}, profile: { provider: 'openai' }, ...SHIPPED })) - .toThrow(/provider openai requires an explicit model/) - }) - - it('trusts a yml-set non-DeepSeek provider only when its own row carries the model', () => { - expect(resolveLlmRoute({ - cli: {}, profile: {}, - gateway: { provider: 'anthropic', model: 'claude-opus-4-8' }, - ymlPiAiProviders: ['openai', 'anthropic'], - })).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined }) - expect(() => resolveLlmRoute({ - cli: {}, profile: {}, - gateway: { provider: 'anthropic' }, - ymlPiAiProviders: ['openai', 'anthropic'], - })).toThrow(/provider anthropic requires an explicit model/) - }) - - it('reuses the SHIPPED cordis.yml roster — the coupling that prevents the duplicate-adapter boot failure', () => { - // Parsed from the real file through the production extraction, not a - // literal roster: renaming the `llm-pi-ai` row or its providers field - // must fail here, because composePatches reads exactly these shapes. - const rows = parseIncludeYmlRows(join(import.meta.dirname, '..', 'cordis.yml')) - const roster = ymlPiAiProvidersOf(rows) - expect(roster).toEqual(['openai', 'anthropic']) - const gateway = (rows.get('api-gateway')?.config ?? {}) as { provider?: unknown; model?: unknown } - expect(resolveLlmRoute({ - cli: { provider: 'anthropic', model: 'claude-opus-4-8' }, profile: {}, - gateway, ymlPiAiProviders: roster, - })).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined }) - }) - - it('fails loud on a missing or empty provider', () => { - expect(() => resolveLlmRoute({ cli: {}, profile: {}, gateway: {}, ymlPiAiProviders: [] })) - .toThrow(/provider must be a non-empty string/) - expect(() => resolveLlmRoute({ cli: { provider: '' }, profile: {}, ...SHIPPED })) - .toThrow(/provider must be a non-empty string/) - }) -}) diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 147f36ba75..5b3483dd7a 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 3ceb4f56b672170f7a68532718fdd6e249c7b1bc -README.zh.md: fdb67792f479b6150f3918db825cfaf6efcd12e5 +README.md: 6f4d8bf15fb581d184a1bb36c912a88a319adf26 +README.zh.md: 6ae5291aee8e7e12d78a9c06c2ed9a1432b4f2a0 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 3ceb4f56b6..6f4d8bf15f 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Each successful generation publishes its validated `host.describe` value through `onDescription` before `onConnected`; a business-error response fails the generation like a transport error. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Each successful generation validates `host.describe` before `onConnected`; a business-error response fails the generation like a transport error. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index fdb67792f4..6ae5291aee 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。每个成功连接代都会先通过 `onDescription` 发布经过校验的 `host.describe` 值,再调用 `onConnected`;业务错误响应会像传输错误一样使该连接代失败。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。每个成功连接代都会在 `onConnected` 前校验 `host.describe`;业务错误响应会像传输错误一样使该连接代失败。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index 21b30e25f9..a3d6d67277 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -1,4 +1,4 @@ -import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts' +import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts' /** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; web-cordis §B.1 lists * these as the future `ctx.connection` plugin Config). All fields optional; defaults below. */ @@ -44,8 +44,6 @@ export type ConnectionState = 'connected' | 'reconnecting' export interface ConnectionSinks { onMuxEnvelope?: (envelope: RpcRequest) => void onHostEnvelope?: (envelope: RpcRequest) => void - /** Latest successful host capability snapshot for this connection generation. */ - onDescription?: (description: HostDescription) => void /** After each connection generation is established (both streams open + describe succeeded), first connect included. */ onConnected?: () => void /** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect @@ -144,7 +142,6 @@ export class ConnectionController { } if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake') this.attempt = 0 - this.callSink(() => { this.sinks.onDescription?.(descriptionResult.value) }) this.emitState('connected') this.callSink(this.sinks.onConnected) } catch { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 70c573b587..4d6f5ec861 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1260,20 +1260,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { cwd: '/tmp/fixture', provider: 'fixture', model: 'fx-vision', - activeModel: { - provider: 'fixture', - id: 'fx-vision', - name: 'Fixture Vision', - inputModalities: ['text', 'image'], - outputModalities: ['text', 'image'], - }, - imageLimits: { - maxImageBytes: 5 * 1024 * 1024, - maxImagesPerMessage: 10, - maxMessageImageBytes: 20 * 1024 * 1024, - maxImagePixels: 40_000_000, - mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], - }, attachedSessions, }), // Deterministic native pick: the keyless lanes drive the full diff --git a/packages/client/connection/tests/connection.spec.ts b/packages/client/connection/tests/connection.spec.ts index 9bb4965838..26efec982a 100644 --- a/packages/client/connection/tests/connection.spec.ts +++ b/packages/client/connection/tests/connection.spec.ts @@ -23,11 +23,9 @@ describe('connection lifecycle', () => { it('announces connected after describe + both streams open, then pumps frames to sinks', async () => { const api = new FakeApiClient() const muxSeen: string[] = [] - const descriptions: string[] = [] let connected = 0 const controller = new ConnectionController(api, { onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type), - onDescription: description => descriptions.push(description.version), onConnected: () => { connected++ }, }, FAST) controller.start() @@ -36,7 +34,6 @@ describe('connection lifecycle', () => { api.pushMux(subscribedFrame()) await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) }) expect(api.callsOf('host.describe')).toHaveLength(1) - expect(descriptions).toEqual(['0-fake']) } finally { controller.stop() } diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index be0888fde4..d26b392072 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -8,7 +8,7 @@ * explicit act of widening what features may do to the sessions domain. */ import type { Context } from 'cordis' -import type { HostDescription, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionBinding, SessionListState, SessionProvideDescriptor, @@ -22,11 +22,6 @@ export interface ISessions { readonly list: ObservableSnapshot /** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */ readonly currentProvideInfo: HostObservable - /** - * Read the latest successfully received host capability description. - * @returns host capabilities, or undefined before the first successful handshake. - */ - hostDescription(): HostDescription | undefined /** * Select a session as current. * @param id - session id (must exist in the list; unknown ids fail loud). diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index f2ddb98347..f359eb1eac 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -178,7 +178,6 @@ export function apply(ctx: Context): void { console.error('[web-runtime] history host-frame routing failed:', error) } }, - onDescription: (description) => { sessions.handleDescription(description) }, onConnected: () => { sessions.handleConnected() workspaces.handleConnected() diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 4de2a5d1e5..cfee26f66b 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 { - HostDescription, IApiClient, RpcError, SessionId, WorkspaceId, + IApiClient, RpcError, SessionId, WorkspaceId, } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, @@ -191,7 +191,6 @@ export class SessionsService implements ISessions { private watched: SessionId | undefined /** Removed-while-staged sessions whose teardown waits for the stage to move away. */ private readonly deferredRemovals = new Set() - private description: HostDescription | undefined /** * @param ctx - client root context (scope fibers mount under it). @@ -231,22 +230,6 @@ export class SessionsService implements ISessions { rootCtx.reflect.provide('sessions', this, undefined) } - /** - * Store the latest successful connection-generation host description. - * @param description - capability and deployment snapshot from `host.describe`. - */ - handleDescription(description: HostDescription): void { - this.description = description - } - - /** - * Read the latest host capability snapshot. - * @returns the last successful description, or undefined before connection. - */ - hostDescription(): HostDescription | undefined { - return this.description - } - /** * Register a per-session standard-props provider: every session-scope slot * component receives the contributed members as standard props (`hooks` diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 464daa676a..d5b29f10a9 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -74,12 +74,6 @@ describe('runtime client apply', () => { expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new') // Mux sink and onConnected route without throwing (manager semantics own the behavior). bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never }) - bench.sinks?.onDescription?.({ version: '0', cwd: '/f', attachedSessions: 0 }) - expect((sessions as { hostDescription(): unknown }).hostDescription()).toEqual({ - version: '0', - cwd: '/f', - attachedSessions: 0, - }) bench.sinks?.onConnected?.() }) diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 50988038ca..889b0afe46 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -1,7 +1,6 @@ /** Test-owned sessions face: the SlotsService host contract over declarative fixtures. */ import type { Context } from 'cordis' import type { AttachmentIdType } from '@deepseek-ai/dsh-attachment' -import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { @@ -197,14 +196,6 @@ export class TestSessions implements ISessions { this.list.subscribe(() => { this.channel.publishCurrent() }) } - /** - * Test runtime has no host handshake unless a fixture explicitly supplies one. - * @returns undefined. - */ - hostDescription(): HostDescription | undefined { - return undefined - } - /** * Add a session from a fixture and (by default) make it current. * @param fixture - identity + snapshot/summary overrides + behavior face. diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 6dec592dab..46aa8867e3 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -472,8 +472,6 @@ describe('fixture session face', () => { expect(() => bare.command()).toThrow(/command is not stubbed/) expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/) expect(() => bare.rename()).toThrow(/rename is not stubbed/) - // No host handshake exists in the bench unless a fixture supplies one. - expect(runtime.sessions.hostDescription()).toBeUndefined() await runtime.dispose() }) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 9c431c9a6f..d25348c2e4 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -191,9 +191,9 @@ export function apply(ctx: Context): void { const shell = inputHub.shell(sessionId) return { keyboard: shell, - addImages: (files, current) => { + addImages: (files) => { try { - const images = conversation.createDraftImages(files, current) + const images = conversation.createDraftImages(files) shell.addImages(images.map(image => image.id)) return null } catch (error: unknown) { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index f596ed97f6..11937d2974 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -273,7 +273,7 @@ export interface ComposerBarInjected { /** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */ keyboard: ComposerKeyboard /** Create browser previews and append their ids to the session input state. */ - addImages: (files: readonly File[], current: readonly ComposerAttachment[]) => string | null + addImages: (files: readonly File[]) => string | null /** Release one browser preview and remove its id from the session input state. */ removeImage: (id: string) => void /** Resolve ordered input-state ids to browser-owned draft attachments. */ diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 94afce5a85..d4d162c3d3 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -46,13 +46,9 @@ export interface IConversation { /** * Create runtime-only draft attachments and preview URLs. * @param files - browser-owned image files. - * @param current - images already present in the composer. * @returns ordered descriptors for the input state. */ - createDraftImages( - files: readonly File[], - current?: readonly ComposerAttachment[], - ): readonly ComposerAttachment[] + createDraftImages(files: readonly File[]): readonly ComposerAttachment[] /** * Resolve ordered draft ids to runtime-owned attachments. * @param ids - ordered composer attachment ids. @@ -171,7 +167,6 @@ export class ConversationService extends Service implements IConversation { mode: 'queue' | 'steer', images: readonly File[], ): Promise { - this.validateImages(images, []) const uploaded = await this.serializeImages(images) const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] const result = await session.prompt(content, mode) @@ -181,14 +176,10 @@ export class ConversationService extends Service implements IConversation { /** * Create runtime-only draft attachments and their object URLs. * @param files - browser-owned image files. - * @param current - images already present in the same composer. * @returns ordered attachment descriptors whose ids may enter the input state. */ - createDraftImages( - files: readonly File[], - current: readonly ComposerAttachment[] = [], - ): readonly ComposerAttachment[] { - this.validateImages(files, current) + createDraftImages(files: readonly File[]): readonly ComposerAttachment[] { + for (const file of files) imageMediaType(file.type) return files.map((file) => { const attachment = new BrowserDraftAttachment(file) this.draftAttachments.set(attachment.id, attachment) @@ -324,40 +315,6 @@ export class ConversationService extends Service implements IConversation { return sessions } - /** Apply host-advertised fast-path checks before any object URL or base64 allocation. */ - private validateImages( - files: readonly File[], - current: readonly ComposerAttachment[], - ): void { - if (files.length === 0 && current.length === 0) return - // Deployment-wide limits only. Model capability is deliberately NOT - // checked here: the handshake's activeModel is the host default, not the - // session's current target (session.selectModel never refreshes it), so a - // client-side modality gate refuses sessions the host would accept and - // vice versa. The host preflight on session.prompt is the authority; its - // rejection renders through the composer error strip. - const description = this.requireSessions().hostDescription() - const limits = description?.imageLimits - const all = [...current.map(attachment => attachment.file), ...files] - if (limits !== undefined && all.length > limits.maxImagesPerMessage) { - throw new Error(`每条消息最多添加 ${limits.maxImagesPerMessage} 张图片`) - } - let totalBytes = 0 - for (const file of all) { - const mediaType = imageMediaType(file.type) - if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) { - throw new Error(`当前部署不支持 ${mediaType} 图片`) - } - if (limits !== undefined && file.size > limits.maxImageBytes) { - throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`) - } - totalBytes += file.size - } - if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) { - throw new Error('图片总大小超过单条消息限制') - } - } - /** Convert browser files to the prompt wire's canonical base64 image parts. */ private serializeImages(images: readonly File[]): Promise[0]> { return Promise.all(images.map(async file => ({ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 2abf77110e..e3afcfbec8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -234,7 +234,7 @@ export function InputBar({ .map(item => item.getAsFile()) .filter((file): file is File => file !== null) if (files.length > 0) { - setDropError(addImages(files, attachments)) + setDropError(addImages(files)) } const text = e.clipboardData.getData('text/plain') if (text === '') { @@ -290,7 +290,7 @@ export function InputBar({ if (locked || machineBusy) return const dropped = [...event.dataTransfer.files] if (dropped.length === 0) return - setDropError(addImages(dropped, attachments)) + setDropError(addImages(dropped)) } const closePreview = useCallback(() => { setPreview(null) }, []) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index b371298e1e..5a5ea3dd2b 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -49,7 +49,7 @@ interface BenchOptions { leftItems?: React.ReactNode rightItems?: React.ReactNode attachments?: readonly ComposerAttachment[] - addImages?: (files: readonly File[], current: readonly ComposerAttachment[]) => string | null + addImages?: (files: readonly File[]) => string | null } /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ @@ -464,7 +464,7 @@ describe('image draft rail', () => { getData: () => '同时粘贴的文字', }, }) - expect(addImages).toHaveBeenCalledWith([image], []) + expect(addImages).toHaveBeenCalledWith([image]) expect(shell.snapshot.draft).toBe('同时粘贴的文字') const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' }) @@ -494,7 +494,7 @@ describe('image draft rail', () => { expect(dataTransfer.dropEffect).toBe('copy') expect(fireEvent.drop(card, { dataTransfer })).toBe(false) expect(view.queryByRole('status')).toBeNull() - expect(addImages).toHaveBeenCalledWith([image], []) + expect(addImages).toHaveBeenCalledWith([image]) }) it('ignores unsupported dropped files and refuses drops while locked', () => { @@ -507,7 +507,7 @@ describe('image draft rail', () => { dataTransfer: { types: ['Files'], files: [documentFile], dropEffect: 'none' }, }) expect(view.getByText(/不支持的图片格式/)).toBeTruthy() - expect(addImages).toHaveBeenCalledWith([documentFile], []) + expect(addImages).toHaveBeenCalledWith([documentFile]) const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' }) const locked = bench({ disabled: true, addImages }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index a73b119ac4..5f8d77e335 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -69,6 +69,31 @@ describe('ConversationService', () => { await b.runtime.dispose() }) + it('checks media type before preview allocation and leaves deployment limits to the host', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockImplementation(file => `blob:${(file as File).name}`) + try { + const files = Array.from( + { length: 11 }, + (_, index) => new File([Uint8Array.of(index)], `${index}.png`, { type: 'image/png' }), + ) + expect(b.root.createDraftImages(files)).toHaveLength(11) + expect(created).toHaveBeenCalledTimes(11) + + const beforeRejectedBatch = created.mock.calls.length + expect(() => { + b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }), + new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }), + ]) + }).toThrow('不支持的图片格式:image/svg+xml') + expect(created).toHaveBeenCalledTimes(beforeRejectedBatch) + } finally { + created.mockRestore() + } + await b.runtime.dispose() + }) + it('releases in-flight send images when the scope dies before the failure lands', async () => { const b = await bench() const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:inflight-1') diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 053f5abf24..1f56a3b5ca 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1419,24 +1419,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, host: { - async describe(request) { - const activeModel = (await ctx.llm.listModels(defaults.provider)) - .find(model => model.id === defaults.model) + describe(request) { // TODO(step2): version should read apps/cli's package.json; placeholder for now. - return ok(request, { + return Promise.resolve(ok(request, { version: '0.0.1', // Same source as session.create's fallback: the UI's default project // must match where an unspecified-cwd session actually lands. cwd: defaults.cwd, provider: defaults.provider, model: defaults.model, - ...activeModel === undefined ? {} : { activeModel }, - imageLimits: { - ...ctx.attachments.imageLimits, - mediaTypes: [...ctx.attachments.imageLimits.mediaTypes], - }, attachedSessions: ctx.agents.list().length, - }) + })) }, async pickDirectory(request, signal) { diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index e9120c9b44..15084b5d07 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -3,14 +3,9 @@ */ import { z } from 'zod' -import type { ModelModality } from '@deepseek-ai/dsh-llm' import type { DirectoryEntry } from './host.ts' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import { imageMediaTypeSchema } from './sessions.schema.ts' - -/** Merge-extensible modality passthrough: declaration merging cannot extend a runtime Zod union. */ -const modalitySchema = z.string() as unknown as z.ZodType /** host.describe request payload (empty object literal). */ export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType>> @@ -21,21 +16,6 @@ export const hostDescribeValueSchema = z.object({ cwd: z.string(), provider: z.string().optional(), model: z.string().optional(), - activeModel: z.object({ - provider: z.string(), - id: z.string(), - name: z.string(), - description: z.string().optional(), - inputModalities: z.array(modalitySchema).optional(), - outputModalities: z.array(modalitySchema).optional(), - }).optional(), - imageLimits: z.object({ - maxImageBytes: z.number().int().positive(), - maxImagesPerMessage: z.number().int().positive(), - maxMessageImageBytes: z.number().int().positive(), - maxImagePixels: z.number().int().positive(), - mediaTypes: z.array(imageMediaTypeSchema), - }).optional(), attachedSessions: z.number().int().nonnegative(), }) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 1b53d6caa8..3d0713e523 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -4,8 +4,6 @@ */ import type { RpcRequest, RpcResponse } from './rpc.ts' -import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' -import type { LlmModelInfo } from '@deepseek-ai/dsh-llm/types' /** One directory row of a listing: a child entry or a breadcrumb ancestor. */ export interface DirectoryEntry { @@ -49,10 +47,6 @@ export interface HostApi { cwd: string provider?: string model?: string - /** Catalog entry for the active route; absent means its capabilities are unknown. */ - activeModel?: LlmModelInfo - /** Resolved authoritative image-upload limits. */ - imageLimits?: ImageAttachmentLimits attachedSessions: number }>> diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 80a46af8cf..244cb8bb8e 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -259,40 +259,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect((await c.host.describe({})).result.ok).toBe(true) }) - it('round-trips a declaration-merged model modality through host.describe', async () => { - const c = client(fakeApi({ - hostDescription: { - version: 'v', - cwd: '/w', - activeModel: { - provider: 'future', - id: 'audio-model', - name: 'Audio Model', - inputModalities: ['text', 'audio'], - outputModalities: ['audio'], - }, - attachedSessions: 0, - }, - })) - - const response = await c.host.describe({}) - expect(response.result).toEqual({ - ok: true, - value: { - version: 'v', - cwd: '/w', - activeModel: { - provider: 'future', - id: 'audio-model', - name: 'Audio Model', - inputModalities: ['text', 'audio'], - outputModalities: ['audio'], - }, - attachedSessions: 0, - }, - }) - }) - it('round-trips the native picker without the default unary timeout', async () => { const api = fakeApi() api.host.pickDirectory = async (request) => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index da3d9a89e5..18b2372787 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -242,32 +242,17 @@ describe('sessions domain schemas', () => { }) describe('host domain schemas', () => { - it('validates describe request/value and preserves merge-extensible modalities', () => { + it('validates describe request/value', () => { expect(hostDescribeRequestSchema.parse({})).toEqual({}) const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', - activeModel: { - provider: 'p', - id: 'm', - name: 'Model', - inputModalities: ['text', 'audio'], - outputModalities: ['text', 'audio'], - }, attachedSessions: 2, }) expect(value.attachedSessions).toBe(2) - expect(value.activeModel?.inputModalities).toEqual(['text', 'audio']) - expect(value.activeModel?.outputModalities).toEqual(['text', 'audio']) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined() - expect(() => hostDescribeValueSchema.parse({ - version: '1', - cwd: '/x', - activeModel: { provider: 'p', id: 'm', name: 'Model', inputModalities: [{ type: 'audio' }] }, - attachedSessions: 0, - })).toThrow() }) it('validates the browse listing/creation payloads', () => { From 6312e43a11d029081be2539e02f1f3465d41b5a7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:58:35 +0800 Subject: [PATCH 018/229] fix: preserve multi-image prompt batches --- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 4 +- ...-image-input-and-durable-attachments.zh.md | 4 +- .../2026-07-29-narrow-web-image-input-v1.md | 35 --------- ...2026-07-29-narrow-web-image-input-v1.zh.md | 35 --------- ...-29-simplify-web-image-input-v1.i18n.yaml} | 6 +- .../2026-07-29-simplify-web-image-input-v1.md | 37 ++++++++++ ...26-07-29-simplify-web-image-input-v1.zh.md | 37 ++++++++++ apps/web/tests/image-display.snapshot.ts | 17 ++--- docs/config-catalog.md | 6 +- docs/cordis-catalog/services.md | 7 ++ .../core-data-structures/attachment.i18n.yaml | 4 +- docs/core-data-structures/attachment.md | 4 +- docs/core-data-structures/attachment.zh.md | 4 +- .../attachment/attachment-local/src/index.ts | 20 +++++- .../attachment/attachment-local/src/store.ts | 9 +++ .../attachment-local/tests/index.spec.ts | 22 ++++++ .../attachment-local/tests/store.spec.ts | 2 + .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/attachment/attachment/src/index.ts | 7 ++ packages/attachment/attachment/src/types.ts | 2 + .../client/connection/src/client/fixture.ts | 2 + packages/client/connection/src/index.ts | 2 +- .../client/connection/tests/node-half.spec.ts | 2 +- .../ui-conversation/src/client/service.ts | 11 ++- .../tests/service-orchestration.spec.ts | 60 +++++++++++----- .../cordis/tool-cordis/src/api-catalog.ts | 4 ++ packages/host/apiproxy/src/api-proxy.ts | 39 ++++++---- packages/host/apiproxy/src/api/host.schema.ts | 2 + .../apiproxy/tests/api-proxy-models.spec.ts | 71 +++++++++++++++---- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 6 ++ .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 6 ++ scripts/test-invariants.ts | 6 ++ 35 files changed, 336 insertions(+), 149 deletions(-) delete mode 100644 .agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.md delete mode 100644 .agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.zh.md rename .agents/notes/implemented/simplification/{2026-07-29-narrow-web-image-input-v1.i18n.yaml => 2026-07-29-simplify-web-image-input-v1.i18n.yaml} (56%) create mode 100644 .agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md create mode 100644 .agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 4a36c145fd..fcaca7a4c7 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: ab1bd449f065346bc327984eacc85f71d4fd5a28 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: d51f9135352ba09b2cd2a083ade6b62f4a276216 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 134e3cf6137abec41398d7f4ded9b838e197a5fc +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: f0c16d10c57a499b605fffead9f35eb2f6149004 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index ab1bd449f0..134e3cf613 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -112,7 +112,7 @@ type PromptInputPart = } ``` -Base64 crosses JSON-RPC once and is discarded after persistence. Version one accepts at most one image per prompt. The host validates canonical base64, individual bytes, magic-byte MIME, intrinsic dimensions, and intrinsic pixel count while durably saving that image. Only after the save succeeds does it call the agent with normalized text and a durable image block. A failure appends no user event and exposes no attachment path or raw bytes. +Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, magic-byte MIME, intrinsic dimensions, and decoded-pixel count: it validates the complete batch through the seam's storage-free `validateImage` before saving any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Only after every image succeeds does it call the agent with normalized text and durable image blocks in the submitted order. A failure appends no user event and exposes no attachment path or raw bytes. `session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client deduplicates loads by session and attachment identifier while that session is rendered, revokes resolved URLs on rendered-session disposal, and invalidates late loads so an unmounted session cannot repopulate the cache. @@ -138,7 +138,7 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state ### Limits and trust boundaries -Version one accepts at most one PNG, JPEG, WebP, or GIF per prompt. SVG and remote URLs are excluded. Default deployment limits are 5 MiB and 40 million intrinsic pixels per image; they are validated backend configuration and projected to the client for fast-path guidance, while host validation remains authoritative. The client connection carrier independently caps buffered API request bodies from the single-image byte limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. +Version one accepts up to 10 ordered PNG, JPEG, WebP, or GIF images per prompt by default. SVG and remote URLs are excluded. Default deployment limits are 5 MiB and 40 million intrinsic pixels per image plus 20 MiB aggregate image bytes per prompt; all are validated backend configuration and projected to the client for fast-path guidance, while host validation remains authoritative. The client connection carrier independently caps buffered API request bodies from the aggregate image-byte limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. Malformed base64, unsupported or mismatched media, truncated headers, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index d51f913535..f0c16d10c5 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -112,7 +112,7 @@ type PromptInputPart = } ``` -Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。第一版每条提示词最多接受一张图片。宿主在持久保存该图片时,会校验规范 base64、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和固有像素数。只有保存成功后,宿主才会用规范化文本和一个持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 +Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和解码像素数:它会在保存任何成员之前,通过服务边界上不触碰存储的 `validateImage` 校验完整批次,因此一张畸形图片不会把批次中的有效成员留成无引用对象。只有每张图片都成功后,宿主才会用规范化文本和按提交顺序排列的持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 `session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并使延迟完成的加载失效,以免已卸载的会话重新写入缓存。 @@ -138,7 +138,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme ### 限制与信任边界 -第一版每条提示词最多接受一张 PNG、JPEG、WebP 或 GIF 图片。不接受 SVG 和远程 URL。默认部署限制为每张图片 5 MiB 和 4,000 万个固有像素;这些限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引,而宿主校验仍是权威结果。客户端连接载体根据单张图片字节上限加上 base64 和请求封装的膨胀量,独立限制 API 请求体的缓冲大小;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 +第一版默认每条提示词最多接受 10 张有序的 PNG、JPEG、WebP 或 GIF 图片。不接受 SVG 和远程 URL。默认部署限制为每张图片 5 MiB 和 4,000 万个固有像素,外加每条提示词 20 MiB 图片总字节数;这些限制都属于经过校验的后端配置,并会投影给客户端以提供快速路径指引,而宿主校验仍是权威结果。客户端连接载体根据图片总字节数上限加上 base64 和请求封装的膨胀量,独立限制 API 请求体的缓冲大小;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 格式错误的 base64、不支持或不匹配的媒体、截断的文件头、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 diff --git a/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.md b/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.md deleted file mode 100644 index 5a42c7acea..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: Narrow Web image input version one - -Status: implemented - -English | [中文](2026-07-29-narrow-web-image-input-v1.zh.md) - -## Problem - -The first durable Web image-input slice also introduced speculative surfaces for multiple-image transactions, arbitrary CLI provider mounting, output-modality discovery, alternative text, and provider-neutral visual token pricing. None was required to paste or drop one image, persist it before its message event, replay it through a visual adapter, or render it from authorized history. Keeping those surfaces would turn unchosen future behavior into public contracts and make the initial capability harder to review and maintain. - -## Decision - -Version one accepts at most one image in a submitted prompt. The client gives immediate feedback, the host enforces the invariant, and the attachment seam validates and commits that one object. Deployment configuration retains per-image byte and pixel limits; request buffering derives from the per-image byte limit. There is no batch prevalidation, aggregate byte limit, image-count setting, transaction layer, or rollback protocol. - -The CLI only patches the selected provider and model. The boot composition must already register the route, as it does for the shipped DeepSeek, OpenAI, and Anthropic routes; the CLI does not inspect the yml provider roster or dynamically mount an adapter. - -Exact-model metadata carries only the input modalities that current admission decisions consume. `ImageBlock` carries the durable attachment reference; its optional display name supplies accessible UI text, so the core block has no separate alternative-text field. Provider-neutral token estimation does not apply one provider's visual pricing formula to other routes. - -The attachment seam exposes its limits plus `saveImage` and `readImage`. The host depends on that seam rather than implementation re-exports. Browser draft and historical-image operations remain concrete conversation-plugin internals; the public `IConversation` face contains only the input registry and the scoped send, cancel, and history verbs used across package boundaries. - -## Alternatives considered - -**Keep multiple images and add transaction or rollback machinery.** Without garbage collection, a partially persisted batch needs an ownership or reclamation design. One image satisfies the initial user path without creating that lifecycle. - -**Keep future-facing fields and methods as placeholders.** Output modalities, block alternative text, batch validation, and active-model handshake data had no current decision consumer. Adding them later with their first consumer preserves freedom to choose the correct contract. - -**Estimate every image with one tile formula.** Visual pricing varies by provider, model, detail mode, and preprocessing. A hard-coded provider-neutral estimate would look authoritative while being wrong; provider usage is the authoritative accounting source. - -**Mount any CLI-selected provider dynamically.** Configuration already owns plugin composition and credentials. Making selection also mutate composition duplicates that responsibility and requires parsing the config tree outside the loader. - -## Consequences - -The initial feature has fewer public fields, lifecycle operations, configuration knobs, and route-assembly branches. A prompt needing multiple images is rejected and must wait for an explicit multi-image persistence design. A provider absent from the composition cannot be selected solely with CLI flags. Pre-request token pressure may undercount visual input until a provider-aware estimator is designed, while reported usage remains exact. - -Reintroducing any removed surface requires a concrete consumer and its failure, lifecycle, replay, and testing contract rather than compatibility with this pre-release shape. diff --git a/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.zh.md b/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.zh.md deleted file mode 100644 index 0459074d01..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.zh.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: 收窄 Web 图片输入第一版 - -Status: implemented - -[English](2026-07-29-narrow-web-image-input-v1.md) | 中文 - -## 问题 - -首个持久化 Web 图片输入切片还引入了多张图片事务、由 CLI(命令行界面)挂载任意提供方、输出模态发现、替代文本以及与提供方无关的视觉 token 定价等推测性表面。要实现粘贴或拖放一张图片、在其消息事件之前将其持久化、通过视觉适配器回放它,或从经授权的历史记录中渲染它,并不需要上述任何表面。保留这些表面会把尚未选择的未来行为变成公共契约,并使初始能力更难评审和维护。 - -## 决策 - -第一版在每条提交的提示词中最多接受一张图片。客户端立即提供反馈,宿主强制执行该不变量,附件服务边界则校验并提交这一个对象。部署配置保留单张图片的字节和像素限制;请求缓冲上限由单张图片的字节限制派生。不设批量预校验、总字节限制、图片数量设置、事务层或回滚协议。 - -CLI 只修改选定的提供方和模型。启动组合必须已经注册该路由,随项目交付的 DeepSeek、OpenAI 和 Anthropic 路由正是如此;CLI 不会检查 yml 提供方清单,也不会动态挂载适配器。 - -确切模型元数据只携带当前准入决策会消费的输入模态。`ImageBlock` 携带持久附件引用;其可选显示名称提供无障碍 UI 文本,因此核心块没有单独的替代文本字段。与提供方无关的 token 估算不会把某一提供方的视觉定价公式应用于其他路由。 - -附件服务边界公开其限制以及 `saveImage` 和 `readImage`。宿主依赖该服务边界,而不是实现层重新导出的内容。浏览器草稿和历史图片操作仍是具体会话插件的内部实现;公开的 `IConversation` 表面只包含输入注册表,以及跨包边界使用的按作用域发送、取消和历史记录操作。 - -## 曾考虑的替代方案 - -**保留多张图片,并添加事务或回滚机制。** 没有垃圾回收时,部分持久化的批次需要一套所有权或回收设计。一张图片即可满足首个用户路径,而不引入该生命周期。 - -**将面向未来的字段和方法保留为占位符。** 输出模态、块替代文本、批量校验和活跃模型握手数据目前都没有决策消费方。等到第一个消费方出现时再加入这些内容,可以保留选择正确契约的自由。 - -**使用一种图块公式估算每张图片。** 视觉定价因提供方、模型、细节模式和预处理而异。一项硬编码且与提供方无关的估算会看似权威,实际却是错误的;提供方用量才是权威核算来源。 - -**动态挂载 CLI 选择的任意提供方。** 配置已经负责插件组合和凭据。让选择操作同时改变组合会造成职责重复,并要求在加载器之外解析配置树。 - -## 后果 - -初始功能具有更少的公开字段、生命周期操作、配置项和路由组装分支。需要多张图片的提示词会被拒绝,必须等待明确的多张图片持久化设计。未加入组合的提供方无法仅凭 CLI 标志选择。在设计出提供方感知型估算器之前,请求前的 token 压力计算可能少计视觉输入,而上报的用量仍保持精确。 - -重新引入任何已移除表面时,都必须有具体消费方,并为其定义失败、生命周期、回放和测试契约,而不是为了兼容这一预发布形态。 diff --git a/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.i18n.yaml similarity index 56% rename from .agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.i18n.yaml rename to .agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.i18n.yaml index 6255482e78..915431b238 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.md -2026-07-29-narrow-web-image-input-v1.md: 5a42c7acea778f8d1ff00e30bde0f849de12eb6b -2026-07-29-narrow-web-image-input-v1.zh.md: 0459074d0145c0d43008d32196ac6577eca958f3 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md +2026-07-29-simplify-web-image-input-v1.md: 1262f1f42ef0b338c0aa7d29e479d4a599cc17a4 +2026-07-29-simplify-web-image-input-v1.zh.md: 60d9c47be1ebd9f259b44aaa6658e3582dfef2d4 diff --git a/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md new file mode 100644 index 0000000000..1262f1f42e --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md @@ -0,0 +1,37 @@ +# Agent Note: Simplify Web image input version one + +Status: implemented + +English | [中文](2026-07-29-simplify-web-image-input-v1.zh.md) + +## Problem + +The first durable Web image-input slice introduced required ordered multi-image intake alongside speculative surfaces for arbitrary CLI provider mounting, output-modality discovery, alternative text, provider-neutral visual token pricing, and browser lifecycle APIs with no cross-package consumer. Keeping the speculative surfaces would turn unchosen future behavior into public contracts and make the initial capability harder to review and maintain. + +## Decision + +Version one accepts ordered image batches bounded by configurable per-message count and aggregate-byte limits plus per-image byte and pixel limits. The client gives immediate feedback, while the host authoritatively decodes the complete batch, checks its bounds, validates every image without storage writes, and only then saves every image while preserving submitted order in the resulting durable blocks. Request buffering derives from the aggregate image-byte limit. This preserves validation atomicity without adding a batch transaction or rollback protocol. + +The CLI only patches the selected provider and model. The boot composition must already register the route, as it does for the shipped DeepSeek, OpenAI, and Anthropic routes; the CLI does not inspect the yml provider roster or dynamically mount an adapter. + +Exact-model metadata carries only the input modalities that current admission decisions consume. `ImageBlock` carries the durable attachment reference; its optional display name supplies accessible UI text, so the core block has no separate alternative-text field. Provider-neutral token estimation does not apply one provider's visual pricing formula to other routes. + +The attachment seam exposes its limits plus storage-free `validateImage`, `saveImage`, and `readImage`. The host depends on that seam rather than implementation re-exports. Browser draft and historical-image operations remain concrete conversation-plugin internals; the public `IConversation` face contains only the input registry and the scoped send, cancel, and history verbs used across package boundaries. + +## Alternatives considered + +**Accept only one image.** Comparing or combining several images is a current product requirement. Count and aggregate-byte bounds keep that path finite without reducing it to a single image. + +**Add a storage transaction or rollback protocol.** Storage-free validation prevents malformed later members from leaving earlier valid members unreferenced. Stronger all-or-nothing storage across independent content-addressed objects would require ownership or reclamation semantics that the current product path does not need. + +**Keep future-facing fields and methods as placeholders.** Output modalities, block alternative text, and active-model handshake data had no current decision consumer. Adding them later with their first consumer preserves freedom to choose the correct contract. + +**Estimate every image with one tile formula.** Visual pricing varies by provider, model, detail mode, and preprocessing. A hard-coded provider-neutral estimate would look authoritative while being wrong; provider usage is the authoritative accounting source. + +**Mount any CLI-selected provider dynamically.** Configuration already owns plugin composition and credentials. Making selection also mutate composition duplicates that responsibility and requires parsing the config tree outside the loader. + +## Consequences + +The feature retains the two batch limits and one storage-free validation method required by multi-image prompts, while removing unrelated public fields, lifecycle operations, and route-assembly branches. A provider absent from the composition cannot be selected solely with CLI flags. Pre-request token pressure may undercount visual input until a provider-aware estimator is designed, while reported usage remains exact. + +Reintroducing any removed surface requires a concrete consumer and its failure, lifecycle, replay, and testing contract rather than compatibility with this pre-release shape. diff --git a/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.zh.md b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.zh.md new file mode 100644 index 0000000000..60d9c47be1 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 简化 Web 图片输入第一版 + +Status: implemented + +[English](2026-07-29-simplify-web-image-input-v1.md) | 中文 + +## 问题 + +首个持久化 Web 图片输入切片在引入按顺序接收多张图片的必需能力时,也引入了由 CLI(命令行界面)挂载任意提供方、输出模态发现、替代文本、提供方无关的视觉 token 定价,以及没有跨包(package)消费方的浏览器生命周期 API 等推测性表面。保留这些推测性表面会把尚未选择的未来行为变成公共契约,并使初始能力更难评审和维护。 + +## 决策 + +第一版接受有序图片批次,并以可配置的每条消息图片数量与图片总字节数上限,以及单张图片字节数与像素上限约束批次。客户端立即提供反馈,而宿主会以权威方式解码完整批次、检查各项上限、在不写入存储的情况下校验每张图片,之后才保存每张图片,并在生成的持久图片块中保留提交顺序。请求缓冲上限由图片总字节数上限派生。这样无需添加批次事务或回滚协议,就能保持校验的原子性。 + +CLI 只修改选定的提供方和模型。启动组合必须已经注册该路由,随项目交付的 DeepSeek、OpenAI 和 Anthropic 路由正是如此;CLI 不会检查 yml 提供方清单,也不会动态挂载适配器。 + +确切模型元数据只携带当前准入决策会消费的输入模态。`ImageBlock` 携带持久附件引用;其可选显示名称提供无障碍 UI 文本,因此核心块没有单独的替代文本字段。与提供方无关的 token 估算不会把某一提供方的视觉定价公式应用于其他路由。 + +附件服务边界公开其限制、不触碰存储的 `validateImage`,以及 `saveImage` 和 `readImage`。宿主依赖该服务边界,而不是实现层重新导出的内容。浏览器草稿和历史图片操作仍是具体会话插件的内部实现;公开的 `IConversation` 表面只包含输入注册表,以及跨包边界使用的按作用域发送、取消和历史记录操作。 + +## 曾考虑的替代方案 + +**只接受一张图片。** 比较或组合多张图片是当前产品要求。图片数量和总字节数上限使这条路径保持有界,而无需将其缩减为单张图片。 + +**添加存储事务或回滚协议。** 不触碰存储的校验能防止后面的畸形成员使前面有效的成员成为无引用对象。若要在独立的内容寻址对象之间实现更强的全有或全无存储保证,就需要当前产品路径并不需要的所有权或回收语义。 + +**将面向未来的字段和方法保留为占位符。** 输出模态、块替代文本和活跃模型握手数据目前都没有决策消费方。等到第一个消费方出现时再加入这些内容,可以保留选择正确契约的自由。 + +**使用一种图块公式估算每张图片。** 视觉定价因提供方、模型、细节模式和预处理而异。一项硬编码且与提供方无关的估算会看似权威,实际却是错误的;提供方用量才是权威核算来源。 + +**动态挂载 CLI 选择的任意提供方。** 配置已经负责插件组合和凭据。让选择操作同时改变组合会造成职责重复,并要求在加载器之外解析配置树。 + +## 后果 + +该功能保留了多图片提示词所需的两个批次上限和一个不触碰存储的校验方法,同时移除了无关的公开字段、生命周期操作和路由组装分支。未加入组合的提供方无法仅凭 CLI 标志选择。在设计出提供方感知型估算器之前,请求前的 token 压力计算可能少计视觉输入,而上报的用量仍保持精确。 + +重新引入任何已移除表面时,都必须有具体消费方,并为其定义失败、生命周期、回放和测试契约,而不是为了兼容这一预发布形态。 diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 1c35a0b818..ed74b317cc 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -5,8 +5,7 @@ // user message and an assistant message, and pins the product surfaces: the // history ImageGallery loading real fixture bytes through the authorized // sessions.attachment route, the double-click ImageLightbox, and the composer -// intake chain (paste → thumbnail rail → one-image limit → image-only send -// enablement → remove). +// intake chain (paste → ordered thumbnail rail → image-only send enablement → remove). import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -162,7 +161,7 @@ it('renders the history image pair through the authorized attachment route and o }) }) -it('accepts a pasted image into the composer rail and removes it', async () => { +it('accepts pasted images into the composer rail in order and removes them', async () => { boot('?fixture=empty') await screen.findByPlaceholderText('Choose a workspace to start', {}, { timeout: 10_000 }) @@ -211,12 +210,14 @@ it('accepts a pasted image into the composer rail and removes it', async () => { getData: () => '', }, }) - expect(await screen.findByText('每条消息最多添加 1 张图片')).toBeTruthy() - expect(rail.querySelectorAll('img')).toHaveLength(1) + await waitFor(() => { + expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))) + .toEqual(['pasted.png', 'second.png']) + }) - const remove = rail.querySelector('button[aria-label^="移除图片"]') - if (remove === null) throw new Error('remove button missing') - fireEvent.click(remove) + const remove = [...rail.querySelectorAll('button[aria-label^="移除图片"]')] + if (remove.length !== 2) throw new Error('remove buttons missing') + for (const button of remove) fireEvent.click(button) await waitFor(() => { expect(document.querySelector('[role="group"][aria-label="待发送图片"]')).toBeNull() }) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ff036de79f..f04fc46ceb 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -196,12 +196,16 @@ export interface Config { dshHome?: string /** Maximum encoded bytes accepted for one image. */ maxImageBytes?: number + /** Maximum image count accepted in one submitted message. */ + maxImagesPerMessage?: number + /** Maximum aggregate encoded image bytes accepted in one submitted message. */ + maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one image. */ maxImagePixels?: number } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:20`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:24`](../packages/attachment/attachment-local/src/index.ts) ## `@deepseek-ai/dsh-bash-local` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 99c25ac695..2f75ba7aa6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -260,6 +260,13 @@ Source: [`packages/ui/user-approval/src/index.ts:217`](../../packages/ui/user-ap Immutable binary attachment service. Implementations validate bytes before publishing a reference. ```ts cordis-catalog +/** + * Validate one image without persisting it. + * Batch callers validate every member before saving any member. + * @param input - encoded bytes, declared media type, and optional display name. + */ +abstract validateImage(input: SaveImageAttachment): void + /** * Validate and durably commit one image before its owning session event is appended. * @param input - encoded bytes, declared media type, and optional display name. diff --git a/docs/core-data-structures/attachment.i18n.yaml b/docs/core-data-structures/attachment.i18n.yaml index a67bd99e1b..ccbe788946 100644 --- a/docs/core-data-structures/attachment.i18n.yaml +++ b/docs/core-data-structures/attachment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/attachment.md -attachment.md: a06142b718dfcdc24ab037987a771869d68b31fa -attachment.zh.md: d41a3bf9cd49c7abb684c2476a1ade6e2d373732 +attachment.md: 566f0198ce6e797e270b63e3cfd255a0025d7135 +attachment.zh.md: 95d755407b3b2ea7ab5b109f2e78edea16ccdd9c diff --git a/docs/core-data-structures/attachment.md b/docs/core-data-structures/attachment.md index a06142b718..566f0198ce 100644 --- a/docs/core-data-structures/attachment.md +++ b/docs/core-data-structures/attachment.md @@ -39,6 +39,8 @@ interface ImageAttachmentRef { /** Deployment-resolved limits shared by upload consumers and UI preflight. */ interface ImageAttachmentLimits { maxImageBytes: number + maxImagesPerMessage: number + maxMessageImageBytes: number maxImagePixels: number mediaTypes: readonly ImageMediaType[] } @@ -67,4 +69,4 @@ interface StoredImageAttachment { } ``` -`saveImage()` validates bytes and atomically commits one object before returning its reference. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. +`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so admission rejection leaves no partial objects behind. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. diff --git a/docs/core-data-structures/attachment.zh.md b/docs/core-data-structures/attachment.zh.md index d41a3bf9cd..95d755407b 100644 --- a/docs/core-data-structures/attachment.zh.md +++ b/docs/core-data-structures/attachment.zh.md @@ -39,6 +39,8 @@ interface ImageAttachmentRef { /** Deployment-resolved limits shared by upload consumers and UI preflight. */ interface ImageAttachmentLimits { maxImageBytes: number + maxImagesPerMessage: number + maxMessageImageBytes: number maxImagePixels: number mediaTypes: readonly ImageMediaType[] } @@ -67,4 +69,4 @@ interface StoredImageAttachment { } ``` -`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 +`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此准入拒绝不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 65bfd8a34b..c015887bfc 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -6,13 +6,17 @@ import z from 'schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-paths' -import { readImageFile, saveImageFile } from './store.ts' +import { readImageFile, saveImageFile, validateImageFile } from './store.ts' export { detectImage } from './image.ts' -export { readImageFile, saveImageFile } from './store.ts' +export { readImageFile, saveImageFile, validateImageFile } from './store.ts' /** Default maximum encoded bytes for one image. */ export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024 +/** Default maximum images in one prompt. */ +export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 10 +/** Default maximum aggregate image bytes in one prompt. */ +export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 20 * 1024 * 1024 /** Default maximum intrinsic pixels for one image. */ export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000 @@ -22,6 +26,10 @@ export interface Config { dshHome?: string /** Maximum encoded bytes accepted for one image. */ maxImageBytes?: number + /** Maximum image count accepted in one submitted message. */ + maxImagesPerMessage?: number + /** Maximum aggregate encoded image bytes accepted in one submitted message. */ + maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one image. */ maxImagePixels?: number } @@ -31,6 +39,8 @@ export class LocalAttachmentStore extends AttachmentStore { static Config: z = z.object({ dshHome: z.string(), maxImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_BYTES), + maxImagesPerMessage: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_MESSAGE), + maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES), maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS), }) @@ -43,11 +53,17 @@ export class LocalAttachmentStore extends AttachmentStore { this.root = resolve(join(resolveDshHome(config.dshHome), 'attachments', 'v1')) this.imageLimits = Object.freeze({ maxImageBytes: config.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES, + maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE, + maxMessageImageBytes: config.maxMessageImageBytes ?? DEFAULT_MAX_MESSAGE_IMAGE_BYTES, maxImagePixels: config.maxImagePixels ?? DEFAULT_MAX_IMAGE_PIXELS, mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const), }) } + validateImage(input: SaveImageAttachment): void { + validateImageFile(input, this.imageLimits) + } + async saveImage(input: SaveImageAttachment): Promise { return saveImageFile(this.root, input, this.imageLimits) } diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 18694b1642..b8a5c6aa3c 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -52,6 +52,15 @@ function validateAdmission(metadata: Omit { @@ -13,6 +16,8 @@ describe('local attachment service', () => { const service = new LocalAttachmentStore(new Context(), {}) expect(service.imageLimits).toEqual({ maxImageBytes: DEFAULT_MAX_IMAGE_BYTES, + maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE, + maxMessageImageBytes: DEFAULT_MAX_MESSAGE_IMAGE_BYTES, maxImagePixels: DEFAULT_MAX_IMAGE_PIXELS, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }) @@ -32,4 +37,21 @@ describe('local attachment service', () => { await rm(dshHome, { recursive: true, force: true }) } }) + + it('validates without persisting: a rejected image leaves no storage root behind', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-')) + try { + const service = new LocalAttachmentStore(new Context(), { dshHome }) + expect(() => { service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }) }) + .toThrow(/Unsupported or malformed image data/) + const valid = Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', + )) + expect(() => { service.validateImage({ data: valid, mediaType: 'image/png' }) }).not.toThrow() + expect(existsSync(service.root)).toBe(false) + } finally { + await rm(dshHome, { recursive: true, force: true }) + } + }) }) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 43ba76fda4..9287c702ad 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -28,6 +28,8 @@ const PNG = Uint8Array.from(Buffer.from( const LIMITS: ImageAttachmentLimits = { maxImageBytes: 1024, + maxImagesPerMessage: 2, + maxMessageImageBytes: 2048, maxImagePixels: 16, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], } diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 3e5a4a2e32..c75c93eb1a 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/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/attachment/attachment/README.md -README.md: b25ef7bcf89b3b85600ed02ab12eb0cdd1117244 -README.zh.md: f45933c2a011978b31a306a1de80d7f031838c8e +README.md: 4f450316294e554396adb9a8454051a08d9befd3 +README.zh.md: fe51b0003cdf1659c7c56106b97c6f3139ebe890 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index b25ef7bcf8..4f45031629 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `saveImage` validates and commits one image at message submission or while committing structured provider output, before any model-visible session event is published. `readImage` verifies the content-addressed object against its logged metadata. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. ## Model Experience diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index f45933c2a0..fe51b0003c 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -4,7 +4,7 @@ 持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`saveImage` 在提交消息或提交结构化提供方输出时校验并提交一张图片,且发生在发布任何模型可见的会话事件之前。`readImage` 根据已记录的元数据校验内容寻址对象。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。 ## 模型体验 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 000856be78..74e280a70f 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -33,6 +33,13 @@ export abstract class AttachmentStore extends Service { /** Deployment-resolved image policy used by authoritative and fast-path validation. */ abstract readonly imageLimits: ImageAttachmentLimits + /** + * Validate one image without persisting it. + * Batch callers validate every member before saving any member. + * @param input - encoded bytes, declared media type, and optional display name. + */ + abstract validateImage(input: SaveImageAttachment): void + /** * Validate and durably commit one image before its owning session event is appended. * @param input - encoded bytes, declared media type, and optional display name. diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 754a9db072..88b1dceb52 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -36,6 +36,8 @@ export interface ImageAttachmentRef { /** Deployment-resolved limits shared by upload consumers and UI preflight. */ export interface ImageAttachmentLimits { maxImageBytes: number + maxImagesPerMessage: number + maxMessageImageBytes: number maxImagePixels: number mediaTypes: readonly ImageMediaType[] } diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 20abb7f53f..0e5f59a213 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1262,6 +1262,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { model: 'fx-vision', imageLimits: { maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 10, + maxMessageImageBytes: 20 * 1024 * 1024, maxImagePixels: 40_000_000, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }, diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 05c4398763..a26b98f779 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -52,7 +52,7 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { for (const entry of trustedHosts) assertTrustedAuthority(entry) const apiHandler = toFetchHandler(ctx.apiProxy) const maxRequestBodyBytes = Math.ceil( - ctx.attachments.imageLimits.maxImageBytes * 4 / 3, + ctx.attachments.imageLimits.maxMessageImageBytes * 4 / 3, ) + REQUEST_ENVELOPE_HEADROOM_BYTES const route: WebRoute = { kind: 'prefix', diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 7e2277305e..3e64ef8907 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -23,7 +23,7 @@ function fakeHttpServer(routes: WebRoute[]): Pick attachment.file), ...files] - if (all.length > 1) throw new Error('每条消息最多添加 1 张图片') + if (limits !== undefined && all.length > limits.maxImagesPerMessage) { + throw new Error(`每条消息最多添加 ${limits.maxImagesPerMessage} 张图片`) + } + let totalBytes = 0 for (const file of all) { const mediaType = imageMediaType(file.type) if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) { @@ -296,6 +299,10 @@ export class ConversationService extends Service implements IConversation { if (limits !== undefined && file.size > limits.maxImageBytes) { throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`) } + totalBytes += file.size + } + if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) { + throw new Error('图片总大小超过单条消息限制') } } diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index a9e1ce525a..9a90bff422 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -29,25 +29,6 @@ async function bench() { } describe('ConversationService', () => { - it('keeps the browser draft to one image', async () => { - const b = await bench() - const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-one') - const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) - try { - const [first] = b.root.createDraftImages([new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' })]) - if (first === undefined) throw new Error('draft attachment missing') - expect(() => b.root.createDraftImages( - [new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' })], - [first], - )).toThrow('每条消息最多添加 1 张图片') - expect(created).toHaveBeenCalledOnce() - } finally { - created.mockRestore() - revoked.mockRestore() - } - await b.runtime.dispose() - }) - it('routes operations through the public Session binding', async () => { const b = await bench() await b.scoped.send('hello', 'steer') @@ -68,6 +49,47 @@ describe('ConversationService', () => { await b.runtime.dispose() }) + it('accepts ordered batches and preflights their advertised count and aggregate limits', async () => { + const b = await bench() + const described = vi.spyOn(b.runtime.sessions, 'hostDescription').mockReturnValue({ + version: 'test', + cwd: '/tmp', + imageLimits: { + maxImageBytes: 3, + maxImagesPerMessage: 2, + maxMessageImageBytes: 3, + maxImagePixels: 4, + mediaTypes: ['image/png'], + }, + attachedSessions: 1, + }) + const created = vi.spyOn(URL, 'createObjectURL') + .mockReturnValueOnce('blob:first') + .mockReturnValueOnce('blob:second') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + try { + const attachments = b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }), + new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' }), + ]) + expect(attachments.map(attachment => attachment.file.name)).toEqual(['first.png', 'second.png']) + expect(() => b.root.createDraftImages([ + new File([Uint8Array.of(3)], 'third.png', { type: 'image/png' }), + ], attachments)).toThrow('每条消息最多添加 2 张图片') + const first = attachments[0] + if (first === undefined) throw new Error('first draft attachment missing') + expect(() => b.root.createDraftImages([ + new File([Uint8Array.of(3, 4, 5)], 'large.png', { type: 'image/png' }), + ], [first])).toThrow('图片总大小超过单条消息限制') + expect(created).toHaveBeenCalledTimes(2) + } finally { + await b.runtime.dispose() + described.mockRestore() + created.mockRestore() + revoked.mockRestore() + } + }) + it('releases draft images when the session scope is disposed', async () => { const b = await bench() const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1') diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index bb61304db7..d5bb3cea22 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -160,6 +160,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'attachments', summary: 'Immutable binary attachment service.', methods: [ + { + signature: 'abstract validateImage(input: SaveImageAttachment): void', + jsDoc: '/**\n * Validate one image without persisting it.\n * Batch callers validate every member before saving any member.\n * @param input - encoded bytes, declared media type, and optional display name.\n */', + }, { signature: 'abstract saveImage(input: SaveImageAttachment): Promise', jsDoc: '/**\n * Validate and durably commit one image before its owning session event is appended.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns a durable content-addressed reference.\n */', diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c9eae83342..3f65ca3a4b 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -79,23 +79,34 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten if (content.every(part => part.type === 'text')) { return content.map(part => ({ type: 'text', text: part.text })) } - if (content.filter(part => part.type === 'image').length > 1) { - throw new AttachmentError('A prompt may contain at most one image.', 'TOO_MANY_IMAGES') + const limits = ctx.attachments.imageLimits + if (content.filter(part => part.type === 'image').length > limits.maxImagesPerMessage) { + throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') } - const durable: ContentBlock[] = [] - for (const part of content) { - if (part.type === 'text') { - durable.push({ type: 'text', text: part.text }) - continue - } - const attachment = await ctx.attachments.saveImage({ - data: decodeBase64(part.data), - mediaType: part.mediaType, - ...part.name === undefined ? {} : { name: part.name }, + const prepared = content.map(part => part.type === 'text' + ? part + : { part, data: decodeBase64(part.data) }) + const images = prepared.filter((part): part is Extract => 'data' in part) + const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0) + if (totalBytes > limits.maxMessageImageBytes) { + throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') + } + for (const image of images) { + ctx.attachments.validateImage({ + data: image.data, + mediaType: image.part.mediaType, + ...image.part.name === undefined ? {} : { name: image.part.name }, }) - durable.push({ type: 'image', attachment }) } - return durable + return Promise.all(prepared.map(async (item): Promise => { + if (!('data' in item)) return { type: 'text', text: item.text } + const attachment = await ctx.attachments.saveImage({ + data: item.data, + mediaType: item.part.mediaType, + ...item.part.name === undefined ? {} : { name: item.part.name }, + }) + return { type: 'image', attachment } + })) } /** diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index e4ba100f17..6d0b9b9268 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -19,6 +19,8 @@ export const hostDescribeValueSchema = z.object({ model: z.string().optional(), imageLimits: z.object({ maxImageBytes: z.number().int().positive(), + maxImagesPerMessage: z.number().int().positive(), + maxMessageImageBytes: z.number().int().positive(), maxImagePixels: z.number().int().positive(), mediaTypes: z.array(imageMediaTypeSchema), }).optional(), diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index d265759da7..10b3e96e47 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -4,14 +4,14 @@ * models, and the prompt-assembly boundary for a running selection change. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, - LlmResolvedModelInfo, StreamChunk, + LlmResolvedModelInfo, StreamChunk, UserMessage, } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -118,23 +118,66 @@ function expectValue(response: { result: { ok: true; value: T } | { ok: false } describe('Web session model selection', () => { - it('rejects a second prompt image before attachment persistence', async () => { - const { ctx, sessionId } = await harness() + it('accepts ordered multi-image prompts and rejects configured batch-limit excess before persistence', async () => { + const { ctx, agent, sessionId } = await harness() + const validateImage = vi.fn((_input: { data: Uint8Array }): void => {}) + const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => { + return Promise.resolve({ + attachmentId: `att-${String(input.data[0])}`, + mediaType: input.mediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + ...(input.name === undefined ? {} : { name: input.name }), + }) + }) + const followup = vi.fn((_message: UserMessage): void => {}) + Object.assign(agent, { followup }) + ctx.provide('attachments', { + imageLimits: { maxImageBytes: 4, maxImagesPerMessage: 2, maxMessageImageBytes: 4, maxImagePixels: 4, mediaTypes: ['image/png'] }, + validateImage, + saveImage, + } as never) const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) - const image = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AA==' } - const response = await api.sessions.prompt(request({ + const first = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' } + const second = { type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==', name: 'second.png' } + const accepted = await api.sessions.prompt(request({ sessionId, mode: 'queue' as const, - content: [image, image], + content: [first, { type: 'text' as const, text: 'compare' }, second], })) - expect(response.result).toEqual({ - ok: false, - error: { - code: 'attachment-error', - message: 'A prompt may contain at most one image.', - details: { reason: 'TOO_MANY_IMAGES' }, - }, + expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } }) + expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]]) + expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]]) + expect(followup.mock.calls[0]?.[0].content).toEqual([ + { type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'first.png' } }, + { type: 'text', text: 'compare' }, + { type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'second.png' } }, + ]) + + const tooMany = await api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [first, second, first], + })) + expect(tooMany.result).toMatchObject({ + ok: false, error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } }, }) + const tooLarge = await api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [ + { ...first, data: 'AQID' }, + { ...second, data: 'BAUG' }, + ], + })) + expect(tooLarge.result).toMatchObject({ + ok: false, + error: { code: 'attachment-error', details: { reason: 'IMAGES_TOO_LARGE' } }, + }) + expect(validateImage).toHaveBeenCalledTimes(2) + expect(saveImage).toHaveBeenCalledTimes(2) + expect(followup).toHaveBeenCalledTimes(1) await ctx.fiber.dispose() }) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 1f51486584..272135bef9 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -250,10 +250,16 @@ describe('PiAiAdapter provider routing', () => { class LateAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { maxImageBytes: 1, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1, maxImagePixels: 1, mediaTypes: ['image/png'], } + validateImage(_input: SaveImageAttachment): void { + throw new Error('not used') + } + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('not used')) } diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index db5b28bf9f..43bd0833d4 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -68,10 +68,16 @@ async function harness(image?: StoredImageAttachment): Promise { class E2eAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { maxImageBytes: fixture.data.byteLength, + maxImagesPerMessage: 1, + maxMessageImageBytes: fixture.data.byteLength, maxImagePixels: fixture.ref.width * fixture.ref.height, mediaTypes: [fixture.ref.mediaType], } + validateImage(_input: SaveImageAttachment): void { + throw new Error('e2e attachment fixture is read-only') + } + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('e2e attachment fixture is read-only')) } diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index d388f31241..c843f94a5a 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -90,10 +90,16 @@ const ATTACHMENT_COMPANION = '../packages/attachment/attachment-local/src/invari class TestAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { maxImageBytes: 1, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1, maxImagePixels: 1, mediaTypes: ['image/png'], } + validateImage(_input: SaveImageAttachment): void { + throw new Error('test invariant attachment store does not validate images') + } + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('test invariant attachment store does not save images')) } From 515d48875e52d75052d98b010daaceea33b2b82f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:58:36 +0800 Subject: [PATCH 019/229] fix: harden Web image admission --- ...07-29-atomic-web-image-admission.i18n.yaml | 6 + .../2026-07-29-atomic-web-image-admission.md | 29 ++ ...026-07-29-atomic-web-image-admission.zh.md | 29 ++ ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 28 +- ...-image-input-and-durable-attachments.zh.md | 32 +- docs/cordis-catalog/services.md | 3 +- .../core-data-structures/attachment.i18n.yaml | 4 +- docs/core-data-structures/attachment.md | 6 +- docs/core-data-structures/attachment.zh.md | 6 +- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/README.zh.md | 2 +- .../attachment/attachment-local/package.json | 5 +- .../attachment/attachment-local/src/image.ts | 107 ++---- .../attachment/attachment-local/src/index.ts | 4 +- .../attachment/attachment-local/src/store.ts | 29 +- .../attachment-local/tests/image.spec.ts | 113 ++---- .../attachment-local/tests/index.spec.ts | 6 +- .../attachment-local/tests/store.spec.ts | 6 +- packages/attachment/attachment/src/index.ts | 3 +- packages/attachment/attachment/src/types.ts | 4 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- packages/client/ui-conversation/package.json | 2 + .../src/client/contract/slots.ts | 8 +- .../ui-conversation/src/client/index.ts | 2 +- .../src/client/input/contract.ts | 18 +- .../src/client/input/facade.ts | 16 +- .../ui-conversation/src/client/input/hub.ts | 8 +- .../ui-conversation/src/client/service.ts | 23 +- .../ui-conversation/tests/input-bar.spec.tsx | 7 +- .../tests/service-orchestration.spec.ts | 41 ++- packages/client/ui-conversation/tsconfig.json | 3 + .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 219 +++++++----- .../apiproxy/tests/api-proxy-commands.spec.ts | 42 ++- .../apiproxy/tests/api-proxy-models.spec.ts | 141 +++++--- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/adapter.ts | 5 +- packages/llm/llm-pi-ai/src/context.ts | 32 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 12 +- packages/llm/llm-pi-ai/tests/convert.spec.ts | 61 +++- .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 4 +- pnpm-lock.yaml | 331 ++++++++++++++++++ scripts/test-invariants.ts | 4 +- 52 files changed, 999 insertions(+), 444 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml new file mode 100644 index 0000000000..08398b5440 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md +2026-07-29-atomic-web-image-admission.md: 7b2f7aeb43ca1393cdab3abe35916964bc47f0c9 +2026-07-29-atomic-web-image-admission.zh.md: 5a04d2e599744dfe9f92eb64a6c828161c46bf6e diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md new file mode 100644 index 0000000000..7b2f7aeb43 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md @@ -0,0 +1,29 @@ +# Agent Note: Atomic Web image admission + +Status: implemented + +English | [中文](2026-07-29-atomic-web-image-admission.zh.md) + +## Problem + +Image prompt admission and `session.selectModel` each read session modality state across asynchronous model and attachment lookups. Without one ordering boundary, an image prompt could validate an image-capable target while a concurrent selection installed a text-only target, or selection could miss a prompt after inbox dequeue but before its durable message event. Scanning the immutable event log avoided the second race but permanently blocked a text-only selection even after compaction removed the image from current model history. + +## Decision + +Each live Web agent has one private promise chain shared by image-bearing prompt admission and model selection. A failed operation settles its caller normally and leaves the chain usable. Text-only prompts bypass the chain because they cannot change the modality constraint. + +The pending-inbox mirror marks a prompt as claimed at dequeue and retains it until the matching `user/message` or `steering/message` event publishes. If admission ends without publishing, the next dequeue or the transition to idle retires the claimed entry; inbox discard retires the listed work, and session disposal retires every remaining entry. Model selection checks that mirror plus `Session.deriveMessages()`, which is the current model-visible history after compaction. + +Provider adapters remain the final enforcement boundary. The host ordering only prevents its mutable route and pending image state from contradicting each other before request assembly. + +## Alternatives considered + +**Scan every immutable session event.** This catches published images but treats compacted-away content as permanently model-visible, preventing a valid later switch to a text-only route. + +**Retire the pending mirror at inbox dequeue.** Dequeue precedes the durable message append and leaves the exact interval in which model selection can miss both pending and published state. + +**Serialize every prompt and session mutation.** Text-only prompts and unrelated session operations cannot introduce an image requirement. A broader lock would add latency and ownership without closing another modality race. + +## Consequences + +An image prompt and a concurrent model selection have deterministic order, and a text-only target cannot strand an image that has been admitted but not yet published. Selection may wait for an in-flight image admission, while unrelated prompts retain their existing concurrency. Compaction can make a text-only target valid once no pending or derived image remains. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md new file mode 100644 index 0000000000..5a04d2e599 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Web 图片准入的原子性 + +Status: implemented + +[English](2026-07-29-atomic-web-image-admission.md) | 中文 + +## 问题 + +包含图片的提示词准入与 `session.selectModel` 都会在跨越异步模型查询与附件查询的过程中读取会话模态状态。如果没有统一的顺序边界,包含图片的提示词可能在支持图片的目标上通过校验,并发的选择操作却设置了纯文本目标;选择操作也可能在提示词已从 inbox 出队、但其持久消息事件尚未发布时漏掉该提示词。扫描不可变事件日志可以避免第二种竞态,但即使压缩(compaction)已经从当前模型历史中移除图片,仍会永久阻止选择纯文本目标。 + +## 决策 + +每个活跃 Web agent(智能体)都有一条私有 promise 链,由包含图片的提示词准入与模型选择共享。操作失败会照常传递给调用方,且不会使该链失效。纯文本提示词绕过该链,因为它们不会改变模态约束。 + +待处理 inbox 镜像会在提示词出队时将其标记为已认领,并保留到匹配的 `user/message` 或 `steering/message` 事件发布。若准入结束时未发布事件,下一次出队或转为空闲状态会移除已认领的条目;inbox 丢弃会移除列出的工作项,会话 dispose(资源释放)则会移除所有剩余条目。模型选择会检查该镜像以及 `Session.deriveMessages()`;后者表示压缩后模型当前可见的历史。 + +提供方适配器仍是最终的强制检查边界。宿主的顺序控制仅用于避免其可变路由与待发布图片状态在请求组装前彼此矛盾。 + +## 曾考虑的替代方案 + +**扫描每个不可变会话事件。** 这能捕获已发布的图片,但会把经压缩移除的内容视为永久对模型可见,从而阻止之后合法切换到纯文本路由。 + +**在 inbox 出队时退役待处理镜像。** 出队早于持久消息追加,因此恰好会留下一个时间区间,让模型选择既看不到待处理状态,也看不到已发布状态。 + +**序列化每个提示词和会话变更。** 纯文本提示词和无关的会话操作无法引入图片要求。更宽的锁会增加延迟与所有权复杂度,却不会再消除任何模态竞态。 + +## 后果 + +包含图片的提示词准入与并发模型选择之间具有确定的先后顺序,纯文本目标无法使已获准入但尚未发布的图片搁浅。模型选择可能等待正在进行的图片准入完成,而无关提示词仍按现有方式并发处理。当没有图片等待发布,且派生历史经过压缩后也不再含图片时,纯文本目标可以变得有效。 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 4d1bec3b32..04783dff62 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 7965f661dd232c035d986eead08bad0e61fecaf7 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 07586464874c18cc7121ae6cdee07dec379703b0 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 50ba92ed503126fc26859d7646774a5b25bcc4eb +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 93fff2a550fdcfe013110eab28ddba0a38ec7490 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 7965f661dd..50ba92ed50 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -44,7 +44,9 @@ The persistence boundary is message acceptance, not paste: Each session's `InputMachine` state keeps the ordered runtime-only attachment identifiers alongside the live draft. The framework-owned chat store receives only the draft's plain-text persistence mirror, while `ConversationService` owns the corresponding browser-only `File` and object-URL registry: ```ts -export {} +import type { Branded } from '@deepseek-ai/dsh-brand' + +type DraftAttachmentId = Branded<'DraftAttachmentId'> interface ChatStoreState { selection: object | null @@ -54,12 +56,12 @@ interface ChatStoreState { interface InputState { draft: string - imageIds: readonly string[] + imageIds: readonly DraftAttachmentId[] } interface ComposerAttachment { kind: 'image' - id: string + id: DraftAttachmentId file: File previewUrl: string } @@ -67,7 +69,7 @@ interface ComposerAttachment { This split uses the session provide channel's input hook and actions as the single subscription path for live composer state while keeping non-serializable browser objects out of persisted JSON. Only the plain-text draft mirror uses `localStorage`; attachment identifiers, browser `File` objects, and object URLs remain scoped to the live session input shell. Unsent images therefore do not survive reload or session-scope disposal. A native client may stage input in an OS temporary directory, but it must treat that path exactly like the browser object URL: delete it when no longer needed and copy the bytes into the durable store before message acceptance. -The local attachment backend resolves an explicit `dshHome`, then `$DSH_HOME`, then `~/.dsh`. It stores content-addressed objects below `$DSH_HOME/attachments/v1/objects//` with owner-only directory and file permissions. A temporary file is written, synchronized, atomically published, and made durable with a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier, and every read verifies the digest, media type, byte length, width, and height. +The local attachment backend resolves an explicit `dshHome`, then `$DSH_HOME`, then `~/.dsh`. It stores content-addressed objects below `$DSH_HOME/attachments/v1/objects//` with owner-only directory and file permissions. A temporary file is written, synchronized, atomically published, and made durable with a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier. Admission and reads fully decode supported rasters before accepting their format and dimensions, and every read also verifies the digest, byte length, and logged metadata. The store performs no automatic deletion in version one. Sent user images and model-generated images remain reachable for history, resume, and fork. Reference-aware garbage collection needs a separate design because an age-only rule can delete data still referenced by a durable session. Deployment byte and pixel limits are admission policy on writes; reads verify the digest and recorded metadata without reapplying current admission limits, so lowering policy does not invalidate older history. @@ -112,17 +114,17 @@ type PromptInputPart = } ``` -Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, magic-byte MIME, intrinsic dimensions, and decoded-pixel count: it validates the complete batch through the seam's storage-free `validateImage` before saving any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Only after every image succeeds does it call the agent with normalized text and durable image blocks in the submitted order. A failure appends no user event and exposes no attachment path or raw bytes. +Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, the declared MIME against a fully decoded raster, intrinsic dimensions, and decoded-pixel count. It awaits the seam's storage-free `validateImage` for every batch member before saving any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Storage commits then run in submission order to bound full-raster decoder memory. If a later storage I/O operation fails, the host appends no user event, but an earlier immutable content-addressed object may remain unreferenced; version one leaves cleanup to future reference-aware garbage collection instead of adding destructive rollback to the deduplicated store. Only after every image succeeds does it call the agent with normalized text and durable image blocks in the submitted order. A failure exposes no attachment path or raw bytes. -`session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client deduplicates loads by session and attachment identifier while that session is rendered, revokes resolved URLs on rendered-session disposal, and invalidates late loads so an unmounted session cannot repopulate the cache. +`session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client deduplicates loads by session and attachment identifier while that session is rendered, revokes resolved URLs on rendered-session disposal, and rejects invalidated late loads before allocating an object URL so an unmounted session or disposed service cannot repopulate the cache. ### Model capabilities and provider behavior Model catalog entries gain optional merge-extensible input modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. -The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. +The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)). Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history; compaction can remove old images and make a later text-only selection valid. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. -The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, then resolves each durable reference and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. +The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. @@ -140,14 +142,14 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier independently caps buffered API request bodies, deriving the cap from the host attachment service's aggregate image limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. -Malformed base64, unsupported or mismatched media, truncated headers, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. +Malformed base64, unsupported or mismatched media, truncated image payloads, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. ### Package and surface changes | Surface | Responsibility | | --- | --- | | `packages/attachment/attachment` | Opaque attachment identifier, image reference, limits, failures, and `ctx.attachments` service. | -| `packages/attachment/attachment-local` | Private content-addressed storage, image-header validation, integrity verification, and configuration. | +| `packages/attachment/attachment-local` | Private content-addressed storage, complete raster decoding, integrity verification, and configuration. | | `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. | | `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. | | `packages/llm/llm-deepseek` | Reject image content explicitly. | @@ -194,9 +196,9 @@ UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalog ## Testing - Storage tests cover content-addressed deduplication, private permissions, admission failures, corruption/missing-object failures, and reading history after deployment limits are lowered. -- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, and refusal of a text-only `session.selectModel` once the session log carries an image (an accepted switch would strand every later turn). -- Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, and draft/session-scope/application object-URL cleanup; the keyless assembled built-client lane (`apps/web/tests/image-display.snapshot.ts`, `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail. -- Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, nested tool-result images, preserved summary input, and explicit image-output rejection. +- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, image-admission/model-selection races, pending publication, and selection against current derived history after compaction. +- Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, draft/session-scope/application object-URL cleanup, and a deferred historical read that completes after disposal; the keyless assembled built-client lane (`apps/web/tests/image-display.snapshot.ts`, `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail. +- Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, recursively nested tool-result images, preserved summary input, and explicit image-output rejection. - A credentialed real-API test sends a PNG through the Anthropic `claude-opus-4-8` route and requires the model to identify its QR code. - The current production adapter set has no certified image-output route; output-provider certification remains outside version one. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index 0758646487..93fff2a550 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -44,7 +44,9 @@ Status: implemented 每个会话的 `InputMachine` 状态在实时草稿旁保存仅限运行时的有序附件标识符。框架持有的 chat store 只接收草稿的纯文本持久化镜像,`ConversationService` 则持有相应的浏览器专用 `File` 与对象 URL 注册表: ```ts -export {} +import type { Branded } from '@deepseek-ai/dsh-brand' + +type DraftAttachmentId = Branded<'DraftAttachmentId'> interface ChatStoreState { selection: object | null @@ -54,12 +56,12 @@ interface ChatStoreState { interface InputState { draft: string - imageIds: readonly string[] + imageIds: readonly DraftAttachmentId[] } interface ComposerAttachment { kind: 'image' - id: string + id: DraftAttachmentId file: File previewUrl: string } @@ -67,7 +69,7 @@ interface ComposerAttachment { 这一拆分把会话 provide 通道的输入 hook 与 actions 用作实时输入区状态的唯一订阅路径,同时避免把不可序列化的浏览器对象写进持久 JSON。只有纯文本草稿镜像使用 `localStorage`;附件标识符、浏览器 `File` 对象和对象 URL 都限定在实时会话输入外壳的 scope 内。未发送图片因此无法跨重载或会话 scope 释放保留。原生客户端可以在操作系统临时目录中暂存输入,但必须像对待浏览器对象 URL 一样对待该路径:不再需要时删除,并在消息被接受前把字节复制进持久存储。 -本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。服务先写入并同步临时文件,再以原子方式发布,并对发布目录执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中,每次读取都会校验摘要、媒体类型、字节长度、宽度和高度。 +本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。服务先写入并同步临时文件,再以原子方式发布,并对发布目录执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中。写入准入与读取都会完整解码受支持的光栅图片,之后才接受其格式和尺寸;每次读取还会校验摘要、字节长度和已记录的元数据。 第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。部署的字节和像素限制是写入时的准入策略;读取时会校验摘要和已记录的元数据,但不重新应用当前准入限制,因此收紧策略不会导致旧历史记录失效。 @@ -112,23 +114,23 @@ type PromptInputPart = } ``` -Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和解码像素数:它会在保存任何成员之前,通过服务边界上不触碰存储的 `validateImage` 校验完整批次,因此一张畸形图片不会把批次中的有效成员留成无引用对象。只有每张图片都成功后,宿主才会用规范化文本和按提交顺序排列的持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 +Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、声明的 MIME 与完整解码后的光栅图片是否一致、固有尺寸和解码像素数。它会在保存任何成员之前,等待服务边界上不触碰存储的 `validateImage` 完成对每个批次成员的校验,因此一张畸形图片不会把批次中的有效成员留成无引用对象。随后按提交顺序执行存储提交,以限制完整光栅解码器的内存占用。如果后续存储 I/O 操作失败,宿主不会追加用户事件,但先前的不可变内容寻址对象可能保持无引用状态;第一版将清理留给未来按引用感知的垃圾回收,而不向去重存储添加破坏性回滚。只有每张图片都成功后,宿主才会用规范化文本和按提交顺序排列的持久图片块调用 agent。失败时不公开任何附件路径或原始字节。 -`session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并使延迟完成的加载失效,以免已卸载的会话重新写入缓存。 +`session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并在分配对象 URL 前拒绝已失效的延迟加载,以免已卸载的会话或已释放的服务重新写入缓存。 ### 模型能力与提供方行为 模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md))。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标;压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 -Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 +Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。在 ACP(Agent Client Protocol)接口原生支持图片前,ACP 会渲染明确的图片标记,而不是静默省略该块。 -压缩(compaction)会把选定的会话前缀(包含图片引用)回放到已配置的摘要生成路径中。支持视觉的路径会通过适配器解析这些引用;仅文本路径会明确失败,而不是静默丢弃视觉上下文。合成的检查点仍仅包含文本,`compact-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。 +压缩会把选定的会话前缀(包含图片引用)回放到已配置的摘要生成路径中。支持视觉的路径会通过适配器解析这些引用;仅文本路径会明确失败,而不是静默丢弃视觉上下文。合成的检查点仍仅包含文本,`compact-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。 ### 历史渲染与原图预览 @@ -140,14 +142,14 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme 第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体会独立限制 API 请求体的缓冲大小,并根据宿主附件服务的图片总量限制,加上 base64 和请求封装的膨胀量推导上限;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 -格式错误的 base64、不支持或不匹配的媒体、截断的文件头、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 +格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 ### 包与接口变更 | 接口 | 职责 | | --- | --- | | `packages/attachment/attachment` | 不透明附件标识符、图片引用、限制、错误和 `ctx.attachments` 服务。 | -| `packages/attachment/attachment-local` | 私有内容寻址存储、图片头校验、完整性校验和配置。 | +| `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 | | `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 | | `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 | | `packages/llm/llm-deepseek` | 明确拒绝图片内容。 | @@ -177,7 +179,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme ### 在消息与会话日志中内联 base64 -这种方式会在 RPC、事件、历史分页、fork、压缩(compaction)和浏览器存储中复制二进制数据,还会诱使 token 计量把编码文本当成模型文本。单一不可变对象配合小型引用,可以让持久表示保持有界。 +这种方式会在 RPC、事件、历史分页、fork、压缩和浏览器存储中复制二进制数据,还会诱使 token 计量把编码文本当成模型文本。单一不可变对象配合小型引用,可以让持久表示保持有界。 ### 使用浏览器对象 URL、本地路径或提供方 URL 作为规范内容 @@ -194,9 +196,9 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模 ## 测试 - 存储测试覆盖内容寻址去重、私有权限、准入失败、对象损坏或缺失时的失败,以及收紧部署限制后读取历史数据。 -- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体,以及在会话日志已含图片时拒绝切换到纯文本模型的 `session.selectModel`(接受该切换会让此后每一轮都失败)。 -- 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序,以及草稿、会话作用域和应用层级的对象 URL 清理;keyless 的组装后构建产物通道(`apps/web/tests/image-display.snapshot.ts`,`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。 -- 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、嵌套工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。 +- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的竞态、待发布状态,以及压缩后依据当前派生历史进行的选择。 +- 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序、草稿、会话作用域和应用层级的对象 URL 清理,以及一项在释放后才完成的延迟历史读取;keyless 的组装后构建产物通道(`apps/web/tests/image-display.snapshot.ts`,`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。 +- 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、递归嵌套在工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。 - 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG,并要求模型识别其中的二维码。 - 当前生产适配器集合没有经过认证的图片输出路由;输出提供方认证仍不在第一版范围内。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 71d009fea3..f480d9581f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -264,8 +264,9 @@ Immutable binary attachment service. Implementations validate bytes before publi * Validate one image without persisting it. * Batch callers validate every member before saving any member. * @param input - encoded bytes, declared media type, and optional display name. + * @returns completion after the encoded raster has been fully decoded. */ -abstract validateImage(input: SaveImageAttachment): void +abstract validateImage(input: SaveImageAttachment): Promise /** * Validate and durably commit one image before its owning session event is appended. diff --git a/docs/core-data-structures/attachment.i18n.yaml b/docs/core-data-structures/attachment.i18n.yaml index ccbe788946..247079b07f 100644 --- a/docs/core-data-structures/attachment.i18n.yaml +++ b/docs/core-data-structures/attachment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/attachment.md -attachment.md: 566f0198ce6e797e270b63e3cfd255a0025d7135 -attachment.zh.md: 95d755407b3b2ea7ab5b109f2e78edea16ccdd9c +attachment.md: 5423891d2be483364d48d8520a596c52d5f6e66e +attachment.zh.md: d1183b5a984cb5f70fce5aa1b739e280b89c3f47 diff --git a/docs/core-data-structures/attachment.md b/docs/core-data-structures/attachment.md index 566f0198ce..5423891d2b 100644 --- a/docs/core-data-structures/attachment.md +++ b/docs/core-data-structures/attachment.md @@ -36,7 +36,7 @@ interface ImageAttachmentRef { ``` ```ts type-equiv -/** Deployment-resolved limits shared by upload consumers and UI preflight. */ +/** Deployment-resolved limits used by upload admission and request buffering. */ interface ImageAttachmentLimits { maxImageBytes: number maxImagesPerMessage: number @@ -54,7 +54,7 @@ The reference records intrinsic dimensions and encoded length so clients can lay /** Request to validate and durably commit one image. */ interface SaveImageAttachment { data: Uint8Array - /** Caller-declared media type, checked against magic bytes. */ + /** Caller-declared media type, checked against fully decoded bytes. */ mediaType: ImageMediaType /** Optional browser/provider display name; it is never interpreted as a path. */ name?: string @@ -69,4 +69,4 @@ interface StoredImageAttachment { } ``` -`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so admission rejection leaves no partial objects behind. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. +`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so validation rejection leaves no partial objects behind. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. diff --git a/docs/core-data-structures/attachment.zh.md b/docs/core-data-structures/attachment.zh.md index 95d755407b..d1183b5a98 100644 --- a/docs/core-data-structures/attachment.zh.md +++ b/docs/core-data-structures/attachment.zh.md @@ -36,7 +36,7 @@ interface ImageAttachmentRef { ``` ```ts type-equiv -/** Deployment-resolved limits shared by upload consumers and UI preflight. */ +/** Deployment-resolved limits used by upload admission and request buffering. */ interface ImageAttachmentLimits { maxImageBytes: number maxImagesPerMessage: number @@ -54,7 +54,7 @@ interface ImageAttachmentLimits { /** Request to validate and durably commit one image. */ interface SaveImageAttachment { data: Uint8Array - /** Caller-declared media type, checked against magic bytes. */ + /** Caller-declared media type, checked against fully decoded bytes. */ mediaType: ImageMediaType /** Optional browser/provider display name; it is never interpreted as a path. */ name?: string @@ -69,4 +69,4 @@ interface StoredImageAttachment { } ``` -`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此准入拒绝不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 +`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此校验拒绝不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 3b02e4d79c..77b716173f 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-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/attachment/attachment-local/README.md -README.md: 38331df827d64c8370d0208f6898971e46010afa -README.zh.md: bc6a471465c7bffc136e8ec2233e200a292f2d1a +README.md: 310120bd1c3573da1e7c60334d5c2712195f3186 +README.zh.md: 9fd3a857eca1c25c90b665735f67d2c27c92334a diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 38331df827..310120bd1c 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash; reads re-check the digest, media signature, dimensions, and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. +The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index bc6a471465..9fd3a857ec 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布目录执行同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在;读取过程会重新校验摘要、媒体签名、尺寸和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。 +这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布目录执行同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。 diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index b87fd83e79..239063542f 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -20,7 +20,10 @@ "@deepseek-ai/dsh-paths": "^0.0.1", "cordis": "^4.0.0-rc.6" }, - "dependencies": { "schemastery": "^3.18.0" }, + "dependencies": { + "schemastery": "^3.18.0", + "sharp": "^0.35.3" + }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/attachment/attachment-local/src/image.ts b/packages/attachment/attachment-local/src/image.ts index 4e7ae5aabb..1ef30358a2 100644 --- a/packages/attachment/attachment-local/src/image.ts +++ b/packages/attachment/attachment-local/src/image.ts @@ -1,102 +1,45 @@ -/** Minimal raster header validation used before bytes enter durable storage. */ +/** Raster decoding used before bytes enter durable storage. */ +import sharp from 'sharp' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' -/** Decoded metadata from a supported image header. */ +/** Decoded metadata from a supported image. */ export interface DetectedImage { mediaType: ImageMediaType width: number height: number } -function ascii(data: Uint8Array, start: number, value: string): boolean { - /* v8 ignore next -- Every call site establishes the fixed header span before comparing it. */ - if (data.length < start + value.length) return false - for (let i = 0; i < value.length; i++) if (data[start + i] !== value.charCodeAt(i)) return false - return true -} - -function u16be(data: Uint8Array, offset: number): number { - return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(offset) -} - -function u16le(data: Uint8Array, offset: number): number { - return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(offset, true) -} - -function u24le(data: Uint8Array, offset: number): number { - const view = new DataView(data.buffer, data.byteOffset, data.byteLength) - return view.getUint8(offset) | (view.getUint8(offset + 1) << 8) | (view.getUint8(offset + 2) << 16) -} - -function u32be(data: Uint8Array, offset: number): number { - return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset) -} - -function u32le(data: Uint8Array, offset: number): number { - return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset, true) -} - -function dimensions(width: number, height: number, mediaType: ImageMediaType): DetectedImage { - if (width < 1 || height < 1) throw new AttachmentError('Image dimensions must be positive.', 'INVALID_IMAGE') - return { mediaType, width, height } -} - -function jpeg(data: Uint8Array): DetectedImage | null { - if (data[0] !== 0xff || data[1] !== 0xd8) return null - const sof = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]) - let offset = 2 - while (offset + 3 < data.length) { - while (data[offset] === 0xff) offset++ - const marker = data[offset] - if (marker === undefined || marker === 0xd9 || marker === 0xda) break - if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { - offset++ - continue - } - const length = u16be(data, offset + 1) - if (length < 2 || offset + 1 + length > data.length) throw new AttachmentError('JPEG data is truncated.', 'INVALID_IMAGE') - if (sof.has(marker)) { - if (length < 7) throw new AttachmentError('JPEG dimensions are truncated.', 'INVALID_IMAGE') - return dimensions(u16be(data, offset + 6), u16be(data, offset + 4), 'image/jpeg') - } - offset += length + 1 - } - throw new AttachmentError('JPEG dimensions are missing.', 'INVALID_IMAGE') +const MEDIA_TYPES: Readonly> = { + png: 'image/png', + jpeg: 'image/jpeg', + webp: 'image/webp', + gif: 'image/gif', } /** - * Detect a supported raster type and intrinsic dimensions from encoded bytes. + * Decode a supported raster and return its intrinsic metadata. * @param data - complete encoded image bytes. + * @param maxPixels - optional write-time decoded-pixel limit; reads omit it. * @returns verified format and dimensions. */ -export function detectImage(data: Uint8Array): DetectedImage { - if (data.length >= 24 - && data[0] === 0x89 && ascii(data, 1, 'PNG\r\n\u001a\n') && ascii(data, 12, 'IHDR')) { - return dimensions(u32be(data, 16), u32be(data, 20), 'image/png') - } - if (data.length >= 10 && (ascii(data, 0, 'GIF87a') || ascii(data, 0, 'GIF89a'))) { - return dimensions(u16le(data, 6), u16le(data, 8), 'image/gif') - } - const detectedJpeg = jpeg(data) - if (detectedJpeg !== null) return detectedJpeg - if (data.length >= 30 && ascii(data, 0, 'RIFF') && ascii(data, 8, 'WEBP')) { - const declaredLength = u32le(data, 4) + 8 - if (declaredLength > data.length) throw new AttachmentError('WebP data is truncated.', 'INVALID_IMAGE') - if (ascii(data, 12, 'VP8X')) return dimensions(u24le(data, 24) + 1, u24le(data, 27) + 1, 'image/webp') - if (ascii(data, 12, 'VP8L') && data[20] === 0x2f) { - const view = new DataView(data.buffer, data.byteOffset, data.byteLength) - const b0 = view.getUint8(21) - const b1 = view.getUint8(22) - const b2 = view.getUint8(23) - const b3 = view.getUint8(24) - return dimensions(1 + b0 + ((b1 & 0x3f) << 8), 1 + (b1 >> 6) + (b2 << 2) + ((b3 & 0x0f) << 10), 'image/webp') +export async function detectImage(data: Uint8Array, maxPixels?: number): Promise { + try { + const image = sharp(data, { failOn: 'error', limitInputPixels: false }) + const metadata = await image.metadata() + const mediaType = MEDIA_TYPES[metadata.format as string] + if (mediaType === undefined) { + throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE') } - if (ascii(data, 12, 'VP8 ') && data[23] === 0x9d && data[24] === 0x01 && data[25] === 0x2a) { - return dimensions(u16le(data, 26) & 0x3fff, u16le(data, 28) & 0x3fff, 'image/webp') + const { width, height } = metadata + if (maxPixels !== undefined && width * height > maxPixels) { + throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS') } - throw new AttachmentError('WebP dimensions are missing.', 'INVALID_IMAGE') + await image.raw().toBuffer() + return { mediaType, width, height } + } catch (error) { + if (error instanceof AttachmentError) throw error + throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error }) } - throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE') } diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index c015887bfc..7ed4824ef2 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -60,8 +60,8 @@ export class LocalAttachmentStore extends AttachmentStore { }) } - validateImage(input: SaveImageAttachment): void { - validateImageFile(input, this.imageLimits) + async validateImage(input: SaveImageAttachment): Promise { + await validateImageFile(input, this.imageLimits) } async saveImage(input: SaveImageAttachment): Promise { diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index b8a5c6aa3c..f489ae315c 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -38,27 +38,28 @@ function ensureReference(ref: ImageAttachmentRef): string { return match[1] } -function inspectMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType']): Omit { +async function inspectMetadata( + data: Uint8Array, + declaredMediaType: ImageAttachmentRef['mediaType'], + maxPixels?: number, +): Promise> { if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') - const detected = detectImage(data) + const detected = await detectImage(data, maxPixels) if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH') return { ...detected, bytes: data.byteLength } } -function validateAdmission(metadata: Omit, limits: ImageAttachmentLimits): void { - if (metadata.bytes > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') - if (metadata.width * metadata.height > limits.maxImagePixels) { - throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS') - } -} - /** * Run the full admission policy for one image without touching storage. * @param input - encoded bytes and declared metadata. * @param limits - resolved storage policy. + * @returns completion after the encoded raster has been fully decoded. */ -export function validateImageFile(input: SaveImageAttachment, limits: ImageAttachmentLimits): void { - validateAdmission(inspectMetadata(input.data, input.mediaType), limits) +export async function validateImageFile(input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise { + if (input.data.byteLength > limits.maxImageBytes) { + throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') + } + await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels) } /** @@ -112,8 +113,8 @@ async function ensureDurableDirectory(path: string, boundary: string): Promise { - const metadata = inspectMetadata(input.data, input.mediaType) - validateAdmission(metadata, limits) + if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') + const metadata = await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels) const sha256 = digest(input.data) const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') @@ -187,7 +188,7 @@ export async function readImageFile(root: string, ref: ImageAttachmentRef): Prom throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error }) } if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') - const metadata = inspectMetadata(data, ref.mediaType) + const metadata = await inspectMetadata(data, ref.mediaType) if (metadata.bytes !== ref.bytes || metadata.width !== ref.width || metadata.height !== ref.height) { throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT') } diff --git a/packages/attachment/attachment-local/tests/image.spec.ts b/packages/attachment/attachment-local/tests/image.spec.ts index 04cbe984fd..ccfe7f62e7 100644 --- a/packages/attachment/attachment-local/tests/image.spec.ts +++ b/packages/attachment/attachment-local/tests/image.spec.ts @@ -1,93 +1,42 @@ +import sharp from 'sharp' import { describe, expect, it } from 'vitest' import { detectImage } from '../src/image.ts' -function bytes(text: string): number[] { - return [...Buffer.from(text, 'ascii')] +async function raster(format: 'png' | 'jpeg' | 'webp' | 'gif'): Promise { + const image = sharp({ + create: { width: 3, height: 2, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 1 } }, + }) + return new Uint8Array(await image.toFormat(format).toBuffer()) } -function webp(chunk: string, mutate: (data: Uint8Array) => void): Uint8Array { - const data = new Uint8Array(30) - data.set(bytes('RIFF'), 0) - data.set([22, 0, 0, 0], 4) - data.set(bytes('WEBP'), 8) - data.set(bytes(chunk), 12) - mutate(data) - return data -} - -describe('raster header detection', () => { - it('detects PNG dimensions', () => { - const data = Uint8Array.from(Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', - 'base64', - )) - expect(detectImage(data)).toEqual({ mediaType: 'image/png', width: 1, height: 1 }) +describe('raster decoding', () => { + it('decodes every supported format and its intrinsic dimensions', async () => { + for (const [format, mediaType] of [ + ['png', 'image/png'], + ['jpeg', 'image/jpeg'], + ['webp', 'image/webp'], + ['gif', 'image/gif'], + ] as const) { + await expect(detectImage(await raster(format))) + .resolves.toEqual({ mediaType, width: 3, height: 2 }) + } }) - it('detects both GIF revisions and rejects zero dimensions', () => { - expect(detectImage(Uint8Array.from([...bytes('GIF87a'), 3, 0, 2, 0]))) - .toEqual({ mediaType: 'image/gif', width: 3, height: 2 }) - expect(detectImage(Uint8Array.from([...bytes('GIF89a'), 4, 0, 5, 0]))) - .toEqual({ mediaType: 'image/gif', width: 4, height: 5 }) - expect(() => detectImage(Uint8Array.from([...bytes('GIF89a'), 0, 0, 1, 0]))) - .toThrow(/positive/) - expect(() => detectImage(Uint8Array.from([...bytes('GIF89a'), 1, 0, 0, 0]))) - .toThrow(/positive/) + it('rejects excess decoded pixels before decoding', async () => { + await expect(detectImage(await raster('png'), 5)) + .rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) }) - it('walks JPEG marker forms and reports malformed dimensions', () => { - const sof = [0xff, 0xc0, 0, 7, 8, 0, 2, 0, 3] - expect(detectImage(Uint8Array.from([0xff, 0xd8, ...sof]))) - .toEqual({ mediaType: 'image/jpeg', width: 3, height: 2 }) - expect(detectImage(Uint8Array.from([ - 0xff, 0xd8, - 0xe0, 0, 2, - 0x01, - 0xff, ...sof, - ]))).toEqual({ mediaType: 'image/jpeg', width: 3, height: 2 }) - - expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xd9, 0, 0, 0]))) - .toThrow(/missing/) - expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xff, 0xff, 0xff, 0xff]))) - .toThrow(/missing/) - expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xe0, 0, 1, 0]))) - .toThrow(/truncated/) - expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xe0, 0, 9, 0]))) - .toThrow(/truncated/) - expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xc0, 0, 6, 0, 0, 0, 0]))) - .toThrow(/dimensions are truncated/) - }) - - it('detects each WebP header and rejects truncated or unknown chunks', () => { - expect(detectImage(webp('VP8X', (data) => { - data.set([2, 0, 0], 24) - data.set([3, 0, 0], 27) - }))).toEqual({ mediaType: 'image/webp', width: 3, height: 4 }) - - expect(detectImage(webp('VP8L', (data) => { - data[20] = 0x2f - data.set([2, 0, 1, 0], 21) - }))).toEqual({ mediaType: 'image/webp', width: 3, height: 5 }) - - expect(detectImage(webp('VP8 ', (data) => { - data.set([0x9d, 0x01, 0x2a], 23) - data.set([6, 0, 7, 0], 26) - }))).toEqual({ mediaType: 'image/webp', width: 6, height: 7 }) - - const truncated = webp('VP8X', () => {}) - truncated[4] = 23 - expect(() => detectImage(truncated)).toThrow(/truncated/) - expect(() => detectImage(webp('NOPE', () => {}))).toThrow(/dimensions are missing/) - expect(() => detectImage(webp('VP8L', () => {}))).toThrow(/dimensions are missing/) - expect(() => detectImage(webp('VP8 ', () => {}))).toThrow(/dimensions are missing/) - }) - - it('rejects unrecognized bytes and near-miss signatures', () => { - expect(() => detectImage(new Uint8Array(0))).toThrow(/Unsupported/) - expect(() => detectImage(Uint8Array.from([...bytes('GIFxxa'), 1, 0, 1, 0]))) - .toThrow(/Unsupported/) - const nearWebp = webp('VP8X', () => {}) - nearWebp[8] = 0 - expect(() => detectImage(nearWebp)).toThrow(/Unsupported/) + it('rejects malformed bytes and truncated payloads with readable headers', async () => { + await expect(detectImage(Uint8Array.of(1, 2, 3))) + .rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + const unsupported = await sharp({ + create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } }, + }).tiff().toBuffer() + await expect(detectImage(unsupported)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + const complete = await raster('png') + const truncated = complete.subarray(0, 62) + await expect(sharp(truncated).metadata()).resolves.toMatchObject({ width: 3, height: 2 }) + await expect(detectImage(truncated)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) }) }) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 22912a2963..7ea71d166b 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -42,13 +42,13 @@ describe('local attachment service', () => { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-')) try { const service = new LocalAttachmentStore(new Context(), { dshHome }) - expect(() => { service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }) }) - .toThrow(/Unsupported or malformed image data/) + await expect(service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' })) + .rejects.toThrow(/Unsupported or malformed image data/) const valid = Uint8Array.from(Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', )) - expect(() => { service.validateImage({ data: valid, mediaType: 'image/png' }) }).not.toThrow() + await expect(service.validateImage({ data: valid, mediaType: 'image/png' })).resolves.toBeUndefined() expect(existsSync(service.root)).toBe(false) } finally { await rm(dshHome, { recursive: true, force: true }) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 9287c702ad..5838b8748c 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { mkdtemp, rm } from 'node:fs/promises' import { afterEach, describe, expect, it, vi } from 'vitest' +import sharp from 'sharp' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import { readImageFile, saveImageFile } from '../src/store.ts' @@ -122,8 +123,9 @@ describe('local attachment store', () => { data: PNG, mediaType: 'image/png', }, { ...LIMITS, maxImageBytes: 1 })).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) - const wide = PNG.slice() - wide.set([0, 0, 0, 5, 0, 0, 0, 5], 16) + const wide = new Uint8Array(await sharp({ + create: { width: 5, height: 5, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } }, + }).png().toBuffer()) await expect(saveImageFile(storageRoot, { data: wide, mediaType: 'image/png', }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 74e280a70f..ebe6ad59c3 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -37,8 +37,9 @@ export abstract class AttachmentStore extends Service { * Validate one image without persisting it. * Batch callers validate every member before saving any member. * @param input - encoded bytes, declared media type, and optional display name. + * @returns completion after the encoded raster has been fully decoded. */ - abstract validateImage(input: SaveImageAttachment): void + abstract validateImage(input: SaveImageAttachment): Promise /** * Validate and durably commit one image before its owning session event is appended. diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 88b1dceb52..c443cb8763 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -33,7 +33,7 @@ export interface ImageAttachmentRef { name?: string } -/** Deployment-resolved limits shared by upload consumers and UI preflight. */ +/** Deployment-resolved limits used by upload admission and request buffering. */ export interface ImageAttachmentLimits { maxImageBytes: number maxImagesPerMessage: number @@ -45,7 +45,7 @@ export interface ImageAttachmentLimits { /** Request to validate and durably commit one image. */ export interface SaveImageAttachment { data: Uint8Array - /** Caller-declared media type, checked against magic bytes. */ + /** Caller-declared media type, checked against fully decoded bytes. */ mediaType: ImageMediaType /** Optional browser/provider display name; it is never interpreted as a path. */ name?: string diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 825072d603..e1d3155591 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: 74137edccc3ca68c40afd545350ce3811d6ae2e2 -README.zh.md: c974640ba08451d0a666061c6ec46ca88d66a85e +README.md: ad64511120e3d4308ab03bb45de21b7d577b4335 +README.zh.md: 69926a282dea3f13564fe97e75d0c7c937b86335 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 74137edccc..ad64511120 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -22,9 +22,9 @@ Per-session UI state for selection and the active view lives in the declared cha 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. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers 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 resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats. -Image drafts keep only ordered runtime ids in that store. `ConversationService` owns the corresponding browser `File` and object URLs, applies the latest host capability and upload-limit snapshot before allocation, and releases draft URLs on removal or send plus historical URLs when their rendered session unmounts. Paste and drop share the same validation path; mixed clipboard text remains native textarea input. +Image drafts keep only ordered `DraftAttachmentId` values in that store. `ConversationService` owns the corresponding browser `File` and object URLs, rejects unsupported declared image media types before allocating previews, and releases draft URLs on removal or send plus historical URLs when their rendered session unmounts. A historical read that completes after its rendered session or the service is disposed rejects before allocating an object URL. Paste and drop share the same validation path; mixed clipboard text remains native textarea input. -`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). +`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is limited to `apply`/`inject` and contract types; concrete services, implementation components (skeleton, chat rows), and the store factory stay internal. Same-package tests import those internals through `./src/*`. ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c974640ba0..69926a282d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -22,9 +22,9 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。 -图片草稿在该 store 中只保留有序的运行时 id。`ConversationService` 持有对应的浏览器 `File` 和对象 URL,在分配前应用最新的宿主能力与上传限制快照,并在图片移除或发送时释放草稿 URL,在所渲染的会话卸载时释放历史 URL。粘贴与拖放共用同一校验路径;混合剪贴板文本仍由 textarea 原生输入。 +图片草稿在该 store 中只保留有序的 `DraftAttachmentId` 值。`ConversationService` 持有对应的浏览器 `File` 和对象 URL,会在分配预览前拒绝声明媒体类型不受支持的图片,并在图片移除或发送时释放草稿 URL,在所渲染的会话卸载时释放历史 URL。一项历史读取如果在其所渲染的会话卸载或该服务释放后才完成,会在分配对象 URL 前被拒绝。粘贴与拖放共用同一校验路径;混合剪贴板文本仍由 textarea 原生输入。 -`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 +`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层仅限 `apply`/`inject` 与契约类型;具体服务、实现组件(骨架、聊天行)和 store factory 均保持内部状态。同包测试通过 `./src/*` 导入这些内部实现。 ## 模型体验 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 08c1db5736..d765d8310e 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -40,6 +40,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-attachment": "^0.0.1", + "@deepseek-ai/dsh-brand": "^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", @@ -51,6 +52,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 11937d2974..c66e53378b 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -6,14 +6,14 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts' +import type { ComposerKeyboard, DraftAttachmentId, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' /** Browser-owned image that has not crossed the durable host boundary. */ export interface ComposerAttachment { kind: 'image' - id: string + id: DraftAttachmentId file: File previewUrl: string } @@ -275,9 +275,9 @@ export interface ComposerBarInjected { /** Create browser previews and append their ids to the session input state. */ addImages: (files: readonly File[]) => string | null /** Release one browser preview and remove its id from the session input state. */ - removeImage: (id: string) => void + removeImage: (id: DraftAttachmentId) => void /** Resolve ordered input-state ids to browser-owned draft attachments. */ - draftImages: (ids: readonly string[]) => readonly ComposerAttachment[] + draftImages: (ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[] /** Cancel the in-flight turn. */ stop: () => void /** diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index dca423ff4e..1963dcd72e 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -4,8 +4,8 @@ * owns their slot assembly. */ export { apply, inject } from './apply.ts' -export { ConversationService } from './service.ts' export type { IConversation } from './service.ts' +export type { DraftAttachmentId } from './input/contract.ts' export type { CallId, ChatStoreState, SelectionTarget, ViewTab, diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 45ec747f5c..a125348b48 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -6,11 +6,15 @@ * (machine.ts) is package-private and never exported. */ import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, ReferenceInsert, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-slash/client' +/** Browser-runtime identity of one unsent image draft. */ +export type DraftAttachmentId = Branded<'DraftAttachmentId'> + /** * The scoped-event application verbs: the hub's bail listeners call these, * and the boolean answer IS the event's bail value (true ⟺ the machine @@ -28,11 +32,11 @@ export interface SessionInput extends InputTarget { /** Single write path for draft text (all mutation rides machine events). */ setDraft(text: string): void /** Append ordered browser-owned draft attachment ids. */ - addImages(ids: readonly string[]): void + addImages(ids: readonly DraftAttachmentId[]): void /** Remove one browser-owned draft attachment id. */ - removeImage(id: string): void + removeImage(id: DraftAttachmentId): void /** Drop ids whose browser objects no longer exist. */ - pruneImages(ids: readonly string[]): void + pruneImages(ids: readonly DraftAttachmentId[]): void /** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */ submit(mode?: 'queue' | 'steer'): void /** @@ -65,11 +69,11 @@ export interface InputActions { /** Single public draft write path (full next draft; occurrence math via diff scan). */ setDraft(text: string): void /** Append ordered browser-owned draft attachment ids. */ - addImages(ids: readonly string[]): void + addImages(ids: readonly DraftAttachmentId[]): void /** Remove one browser-owned draft attachment id. */ - removeImage(id: string): void + removeImage(id: DraftAttachmentId): void /** Drop ids whose browser objects no longer exist. */ - pruneImages(ids: readonly string[]): void + pruneImages(ids: readonly DraftAttachmentId[]): void /** Enter submission (adjudication / claim transaction / default sink inside). */ submit(mode?: 'queue' | 'steer'): void } @@ -198,7 +202,7 @@ export interface InputMachineOptions { export interface InputState { readonly draft: string /** Ordered runtime-only image ids; bytes and object URLs stay in ConversationService. */ - readonly imageIds: readonly string[] + readonly imageIds: readonly DraftAttachmentId[] /** Monotonic draft revision (span CAS compares against this). */ readonly draftRev: number readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 7e3057d8ab..2d566d7632 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -13,7 +13,7 @@ import type { ReferenceInsert, SlashController, TokenSpan, } from '@deepseek-ai/dsh-client-ui-slash/client' import type { - EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, + DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, PasteComponent, QueuedMessage, SessionInput, SubmitAttempt, } from './contract.ts' import { InputMachine } from './machine.ts' @@ -39,7 +39,7 @@ export interface SessionInputDeps { /** Queue read face; overlaid onto InputState.queue (absent = empty). */ queue?: ObservableSnapshot | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ - defaultSink(text: string, mode: 'queue' | 'steer', imageIds: readonly string[]): void + defaultSink(text: string, mode: 'queue' | 'steer', imageIds: readonly DraftAttachmentId[]): void } /** Guard tier from the machine phase. */ @@ -79,7 +79,7 @@ export class SessionInputShell implements SessionInput { private readonly core = new InputMachine({ now: () => Date.now() }) private noticeSeq = 0 private lastDraft = '' - private imageIds: readonly string[] = [] + private imageIds: readonly DraftAttachmentId[] = [] private disposed = false /** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */ private mirrorFn: ((text: string) => void) | undefined @@ -102,14 +102,14 @@ export class SessionInputShell implements SessionInput { } /** Append ordered browser-owned draft attachment ids. */ - addImages(ids: readonly string[]): void { + addImages(ids: readonly DraftAttachmentId[]): void { if (ids.length === 0 || this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return this.imageIds = [...this.imageIds, ...ids] this.publish() } /** Remove one browser-owned draft attachment id. */ - removeImage(id: string): void { + removeImage(id: DraftAttachmentId): void { const next = this.imageIds.filter(candidate => candidate !== id) if (next.length === this.imageIds.length) return this.imageIds = next @@ -120,7 +120,7 @@ export class SessionInputShell implements SessionInput { * Drop ids whose browser objects no longer exist. * @param available - ids that still resolve through the browser attachment registry. */ - pruneImages(available: readonly string[]): void { + pruneImages(available: readonly DraftAttachmentId[]): void { const keep = new Set(available) const next = this.imageIds.filter(id => keep.has(id)) if (next.length === this.imageIds.length) return @@ -132,7 +132,7 @@ export class SessionInputShell implements SessionInput { * Restore a failed attempt's ids before any images added after submission. * @param ids - ordered identifiers captured by the failed attempt. */ - restoreImages(ids: readonly string[]): void { + restoreImages(ids: readonly DraftAttachmentId[]): void { const current = new Set(this.imageIds) this.imageIds = [...ids.filter(id => !current.has(id)), ...this.imageIds] this.publish() @@ -144,7 +144,7 @@ export class SessionInputShell implements SessionInput { * (the command path gets the same discipline from submit-settled success). * @param imageIds - identifiers included in the committed attempt. */ - commitSend(imageIds: readonly string[]): void { + commitSend(imageIds: readonly DraftAttachmentId[]): void { const submitted = new Set(imageIds) this.imageIds = this.imageIds.filter(id => !submitted.has(id)) this.run(this.core.dispatch({ type: 'send-committed' })) diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index e0568ce70f..6f1c68a12d 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -11,7 +11,7 @@ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client' import { queueReadFaceOf } from '../queue/store.ts' -import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts' +import type { ComposerKeyboard, DraftAttachmentId, InputService, SessionInput } from './contract.ts' import type { PopupDismissFace } from './facade.ts' import { SessionInputShell } from './facade.ts' @@ -26,9 +26,9 @@ interface ConversationAttachmentFace { session: SessionFace, text: string, mode: 'queue' | 'steer', - imageIds: readonly string[], + imageIds: readonly DraftAttachmentId[], ): Promise - releaseDraftImage(id: string): void + releaseDraftImage(id: DraftAttachmentId): void } /** Session-addressed input facade registry (InputService face + composer-layer extras). */ @@ -138,7 +138,7 @@ export class InputHub implements InputService { session: SessionFace, text: string, mode: 'queue' | 'steer', - imageIds: readonly string[], + imageIds: readonly DraftAttachmentId[], ): void { if (text === '' && imageIds.length === 0) return const shell = this.shells.get(session.sessionId) diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 81caad16b6..1f2b1e1edb 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -15,7 +15,7 @@ import type { Context } from 'cordis' import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ComposerAttachment } from './contract/slots.ts' -import type { InputService } from './input/contract.ts' +import type { DraftAttachmentId, InputService } from './input/contract.ts' /** * The outward conversation face (`ctx.conversation`): the scope-addressed @@ -46,7 +46,7 @@ export interface IConversation { /** Create one browser-only draft descriptor; only its id enters input state. */ function browserDraftAttachment(file: File): ComposerAttachment { - return { kind: 'image', id: crypto.randomUUID(), previewUrl: URL.createObjectURL(file), file } + return { kind: 'image', id: crypto.randomUUID() as DraftAttachmentId, previewUrl: URL.createObjectURL(file), file } } interface ImageUrlEntry { @@ -59,10 +59,11 @@ interface ImageUrlEntry { export class ConversationService extends Service implements IConversation { /** The per-session input machine registry (InputService face, design §5.2). */ readonly input: InputService - private readonly draftAttachments = new Map() + private readonly draftAttachments = new Map() private readonly imageUrls = new Map() private readonly imageGenerations = new Map() private readonly createdImageUrls = new Set() + private disposed = false /** * @param ctx - owning root context (the plugin apply context; the service @@ -74,6 +75,7 @@ export class ConversationService extends Service implements IConversation { super(ctx, 'conversation') this.input = config.input ctx.effect(() => () => { + this.disposed = true for (const url of this.createdImageUrls) URL.revokeObjectURL(url) this.createdImageUrls.clear() this.draftAttachments.clear() @@ -107,7 +109,7 @@ export class ConversationService extends Service implements IConversation { session: SessionFace, text: string, mode: 'queue' | 'steer', - imageIds: readonly string[], + imageIds: readonly DraftAttachmentId[], ): Promise { const attachments = this.draftImages(imageIds) if (attachments.length !== imageIds.length) { @@ -149,7 +151,7 @@ export class ConversationService extends Service implements IConversation { * @param ids - ordered ids from the per-session input state. * @returns attachments still available in this browser runtime. */ - draftImages(ids: readonly string[]): readonly ComposerAttachment[] { + draftImages(ids: readonly DraftAttachmentId[]): readonly ComposerAttachment[] { const attachments: ComposerAttachment[] = [] for (const id of ids) { const attachment = this.draftAttachments.get(id) @@ -162,7 +164,7 @@ export class ConversationService extends Service implements IConversation { * Release one draft attachment preview. * @param id - draft-local attachment id. */ - releaseDraftImage(id: string): void { + releaseDraftImage(id: DraftAttachmentId): void { const attachment = this.draftAttachments.get(id) if (attachment === undefined) return this.draftAttachments.delete(id) @@ -185,6 +187,7 @@ export class ConversationService extends Service implements IConversation { * @returns a browser URL for inline and original-size display. */ resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { + if (this.disposed) return Promise.reject(new Error('conversation.resolveImage: service is disposed')) const key = `${sessionId}:${attachment.attachmentId}` const cached = this.imageUrls.get(key) if (cached !== undefined) return cached.pending @@ -194,6 +197,10 @@ export class ConversationService extends Service implements IConversation { const pending = session.readAttachment(attachment.attachmentId) .then((result) => { if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) + if (this.disposed) throw new Error('conversation.resolveImage: service was disposed before loading completed') + if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) { + throw new Error('historical image scope was released before loading completed') + } if (typeof URL.createObjectURL !== 'function') { return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` } @@ -201,10 +208,6 @@ export class ConversationService extends Service implements IConversation { const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType, })) - if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) { - revokePreview(url) - throw new Error('historical image scope was released before loading completed') - } this.createdImageUrls.add(url) return url }) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 5a5ea3dd2b..4afc2dc81c 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -13,6 +13,7 @@ import { SessionInputShell } from '../src/client/input/facade.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import type { ComposerAttachment } from '../src/client/contract/slots.ts' +import type { DraftAttachmentId } from '../src/client/input/contract.ts' afterEach(cleanup) @@ -78,7 +79,7 @@ function bench(over?: BenchOptions) { promptError: over?.promptError ?? null, })) const stop = vi.fn() - const removeImage = vi.fn((id: string) => { shell.removeImage(id) }) + const removeImage = vi.fn((id: DraftAttachmentId) => { shell.removeImage(id) }) const slotCalls: { key: string; owner: unknown }[] = [] const renderSlot = ((key: string, owner: object) => { slotCalls.push({ key, owner }) @@ -523,7 +524,7 @@ describe('image draft rail', () => { it('allows image-only send, removes a thumbnail, and opens original preview on double-click', () => { const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) - const attachment = { kind: 'image' as const, id: 'draft-1', file, previewUrl: 'blob:draft-1' } + const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' } const { view, textarea, sink, removeImage } = bench({ attachments: [attachment] }) const send = view.getByRole('button', { name: 'Send message' }) as HTMLButtonElement expect(send.disabled).toBe(false) @@ -536,7 +537,7 @@ describe('image draft rail', () => { it('opens the original preview on double-click and closes it with Escape', () => { const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) - const attachment = { kind: 'image' as const, id: 'draft-1', file, previewUrl: 'blob:draft-1' } + const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' } const { view } = bench({ attachments: [attachment] }) fireEvent.doubleClick(view.getByTitle('双击查看原图')) expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 5f8d77e335..fc3a23f7dd 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -6,17 +6,19 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client' import { InputHub } from '../src/client/input/hub.ts' +import { ConversationService } from '../src/client/service.ts' -async function bench() { +async function bench(readAttachment?: SessionFace['readAttachment']) { const runtime = await SlotTestRuntime.create() const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const loadOlder = vi.fn(() => Promise.resolve()) await runtime.sessions.add({ id: 's1', - session: { prompt, cancel, loadOlder }, + session: { prompt, cancel, loadOlder, ...(readAttachment === undefined ? {} : { readAttachment }) }, }) // config.input is required (the apply shares its hub with the inject // factories); the bench passes its own instance explicitly. @@ -25,7 +27,7 @@ async function bench() { await fiber.await() const root = runtime.ctx.get('conversation') as ConversationService const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService - return { runtime, hub, root, scoped, prompt, cancel, loadOlder } + return { runtime, fiber, hub, root, scoped, prompt, cancel, loadOlder } } describe('ConversationService', () => { @@ -122,6 +124,37 @@ describe('ConversationService', () => { await b.runtime.dispose() }) + it('does not publish a historical image URL after disposal', async () => { + let resolveRead!: (result: Awaited>) => void + const readAttachment: SessionFace['readAttachment'] = vi.fn(() => new Promise>>( + (resolve) => { resolveRead = resolve }, + )) + const b = await bench(readAttachment) + const created = vi.spyOn(URL, 'createObjectURL') + const sessionId = b.runtime.sessions.behavior('s1').sessionId + const attachment = { + attachmentId: AttachmentId('image-1'), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + } as const + const pending = b.root.resolveImage(sessionId, attachment) + await b.fiber.dispose() + await expect(b.root.resolveImage(sessionId, attachment)).rejects.toThrow('service is disposed') + resolveRead({ + ok: true, + value: { + attachment, + data: Uint8Array.of(1), + }, + }) + await expect(pending).rejects.toThrow('service was disposed before loading completed') + expect(created).not.toHaveBeenCalled() + created.mockRestore() + await b.runtime.dispose() + }) + it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => { const b = await bench() await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index b809c605b1..74fd6f0da7 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../attachment/attachment" }, + { + "path": "../../util/brand" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b6cfbf882a..06e7036e21 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -161,8 +161,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Immutable binary attachment service.', methods: [ { - signature: 'abstract validateImage(input: SaveImageAttachment): void', - jsDoc: '/**\n * Validate one image without persisting it.\n * Batch callers validate every member before saving any member.\n * @param input - encoded bytes, declared media type, and optional display name.\n */', + signature: 'abstract validateImage(input: SaveImageAttachment): Promise', + jsDoc: '/**\n * Validate one image without persisting it.\n * Batch callers validate every member before saving any member.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns completion after the encoded raster has been fully decoded.\n */', }, { signature: 'abstract saveImage(input: SaveImageAttachment): Promise', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 18202ed036..4571d984b6 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 1f0daedc54888a1951bc83c474f83287aaf42307 -README.zh.md: abf5417cdbe93f1199c621ac101249986969da93 +README.md: 693b2c26a9ec1e7ea31030a3028f05706adbfc3b +README.zh.md: b1766d901d9ed5743cbc766166bfb310c565ef5f diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 1f0daedc54..693b2c26a9 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -14,7 +14,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. -Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. +Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Selection is serialized with image-bearing prompt admission and rejects a text-only target while an image is pending publication or remains in the current derived history; an image removed by compaction no longer blocks selection. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `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. 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()`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index abf5417cdb..b1766d901d 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -14,7 +14,7 @@ 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 -会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 +会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。模型选择与包含图片的提示词准入串行执行;当图片正等待发布或仍存在于当前派生历史中时,会拒绝选择纯文本目标;被压缩(compaction)移除的图片不再阻止选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c34093edbc..a7f56df1f7 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -92,30 +92,29 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') } for (const image of images) { - ctx.attachments.validateImage({ + await ctx.attachments.validateImage({ data: image.data, mediaType: image.part.mediaType, ...image.part.name === undefined ? {} : { name: image.part.name }, }) } - return Promise.all(prepared.map(async (item): Promise => { - if (!('data' in item)) return { type: 'text', text: item.text } + const blocks: ContentBlock[] = [] + for (const item of prepared) { + if (!('data' in item)) { + blocks.push({ type: 'text', text: item.text }) + continue + } const attachment = await ctx.attachments.saveImage({ data: item.data, mediaType: item.part.mediaType, ...item.part.name === undefined ? {} : { name: item.part.name }, }) - return { type: 'image', attachment } - })) + blocks.push({ type: 'image', attachment }) + } + return blocks } -/** - * The ONE recursive block walk shared by attachment authorization and the - * model-selection gate (nested tool-result content included). Both consumers - * must agree on what counts as replayed image content — a route added to one - * walker but not the other would silently skip authorization or stranding - * protection — so there is exactly one walker, parameterized by match. - */ +/** Search durable event content for an image reference, including nested tool results. */ function imageBlockIn(content: unknown, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined { if (!Array.isArray(content)) return undefined for (const value of content) { @@ -148,18 +147,15 @@ function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => b return undefined } -/** True when any block (nested tool-result content included) is an image block. */ -function contentHasImage(content: unknown): boolean { - return imageBlockIn(content, () => true) !== undefined +/** True when typed model content contains an image, including nested tool results. */ +function contentHasImage(content: readonly ContentBlock[]): boolean { + return content.some(block => block.type === 'image' + || (block.type === 'tool-result' && contentHasImage(block.content))) } -/** - * True when the session log already carries image content on any route a - * model request replays (message content, wrapped messages, streamed blocks). - * The log is immutable, so a true here is permanent for the session's life. - */ -function sessionHasImage(events: readonly SessionEvent[]): boolean { - return events.some(event => imageInEvent(event, () => true) !== undefined) +/** True when the current model-visible surface contains an image. */ +function messagesHaveImage(messages: readonly { content: readonly ContentBlock[] }[]): boolean { + return messages.some(message => contentHasImage(message.content)) } function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined { @@ -564,6 +560,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const pendingQuestions = new Map() const pendingApprovals = new Map() const muxQueues = new Set>>() + const imageAdmissionChains = new WeakMap>() + + /** Serialize model selection with image prompt admission for one agent. */ + function serializeImageAdmission(agent: Agent, operation: () => Promise): Promise { + const result = (imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation) + imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined)) + return result + } /** * Install or return the session-local target that prompt assembly snapshots. @@ -619,18 +623,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * Per-session inbox occurrence mirror serving the mux-open queue snapshot * (the same refresh-recovery baseline as pending questions). Each terminal * inbox event retires one matching occurrence, so repeated sends of the same - * identified message remain visible until every occurrence is claimed. + * identified message remain visible until every occurrence is published or + * discarded. Dequeue is not publication: the log append follows it. */ - const queuedMirror = new Map() + const queuedMirror = new Map() ctx.effect(() => { - const retire = (agent: Agent, id: MessageId, placement?: InboxPlacement): void => { - const entries = queuedMirror.get(agent.id) + const retire = (sessionId: SessionId, id: MessageId, placement?: InboxPlacement): void => { + const entries = queuedMirror.get(sessionId) if (entries === undefined) return const index = entries.findIndex(entry => entry.message.id === id && (placement === undefined || entry.steering === (placement === 'steering'))) if (index !== -1) entries.splice(index, 1) - if (entries.length === 0) queuedMirror.delete(agent.id) + if (entries.length === 0) queuedMirror.delete(sessionId) + } + const retireClaimed = (sessionId: SessionId): void => { + const entries = queuedMirror.get(sessionId) + if (entries === undefined) return + const pending = entries.filter(entry => !entry.claimed) + if (pending.length === 0) queuedMirror.delete(sessionId) + else queuedMirror.set(sessionId, pending) } const disposers = [ ctx.on('agent/inbox/enqueue', (agent: Agent, message: UserMessage, placement) => { @@ -640,7 +652,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queuedMirror.set(agent.id, entries) } const steering = placement === 'steering' - entries.push({ message, steering }) + entries.push({ message, steering, claimed: false }) broadcast({ type: 'session/queued', sessionId: agent.id, @@ -649,10 +661,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) }), ctx.on('agent/inbox/dequeue', (agent: Agent, message: UserMessage, placement) => { - retire(agent, message.id, placement) + // A later claim proves any earlier claimed item either published (and + // was retired by session/event) or its admission ended without one. + retireClaimed(agent.id) + const entry = queuedMirror.get(agent.id)?.find(candidate => + candidate.message.id === message.id + && candidate.steering === (placement === 'steering')) + if (entry !== undefined) entry.claimed = true + }), + ctx.on('session/event', (session: Session, event: SessionEvent) => { + if (event.type === 'user/message') { + retire(session.id, event.data.id, 'queued') + } else if (event.type === 'steering/message') { + retire(session.id, (event.data as { message: UserMessage }).message.id, 'steering') + } }), ctx.on('agent/inbox/discard', (agent: Agent, messages: UserMessage[]) => { - for (const message of messages) retire(agent, message.id) + for (const message of messages) retire(agent.id, message.id) + }), + ctx.on('agent/status', (agent: Agent, status: AgentStatus) => { + if (status === 'idle') retireClaimed(agent.id) }), ctx.on('session/disposed', (session: Session) => { queuedMirror.delete(session.id) @@ -1133,48 +1161,47 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const { sessionId, provider, model, reasoningEffort } = request.payload const found = await agentFor(sessionId) if ('error' in found) return err(request, found.error) - try { - const resolved = await ctx.llm.resolveCallConfig({ - provider, - model, - ...reasoningEffort === undefined - ? {} - : { reasoningEffort: ReasoningEffortId(reasoningEffort) }, - }) - // An image-bearing log replays into every later request, and both - // wire routes reject image content on text-only models — accepting - // this selection would strand the session (every turn fails, no - // in-product recovery). Refuse at the selection boundary instead. - // The pending inbox counts too: a queued image prompt enters the log - // only when claimed, which would happen AFTER this switch landed. - const queuedImage = (queuedMirror.get(sessionId) ?? []) - .some(entry => contentHasImage(entry.message.content)) - if (queuedImage || sessionHasImage(found.agent.session.events)) { - const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model) - if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) { - return err(request, { - code: 'model-unavailable', - message: `Model "${resolved.model}" does not accept image input, but this session's history already contains images; select an image-capable model.`, - details: { provider, model }, - }) + return serializeImageAdmission(found.agent, async () => { + try { + const resolved = await ctx.llm.resolveCallConfig({ + provider, + model, + ...reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(reasoningEffort) }, + }) + // A current image-bearing surface replays into the next request, + // while a dequeued prompt remains pending until its message event + // publishes. Refuse a text-only route at this shared boundary. + const queuedImage = (queuedMirror.get(sessionId) ?? []) + .some(entry => contentHasImage(entry.message.content)) + if (queuedImage || messagesHaveImage(found.agent.session.deriveMessages())) { + const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model) + if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) { + return err(request, { + code: 'model-unavailable', + message: `Model "${resolved.model}" does not accept image input, but this session's history already contains images; select an image-capable model.`, + details: { provider, model }, + }) + } } + const selected: AgentLlmTarget = { + provider: resolved.provider, + model: resolved.model, + ...resolved.reasoningEffort === undefined + ? {} + : { reasoningEffort: resolved.reasoningEffort }, + } + targetFor(found.agent).current = selected + return ok(request, { selected: { ...selected } }) + } catch (error: unknown) { + return err(request, { + code: 'model-unavailable', + message: error instanceof Error ? error.message : String(error), + details: { provider, model }, + }) } - const selected: AgentLlmTarget = { - provider: resolved.provider, - model: resolved.model, - ...resolved.reasoningEffort === undefined - ? {} - : { reasoningEffort: resolved.reasoningEffort }, - } - targetFor(found.agent).current = selected - return ok(request, { selected: { ...selected } }) - } catch (error: unknown) { - return err(request, { - code: 'model-unavailable', - message: error instanceof Error ? error.message : String(error), - details: { provider, model }, - }) - } + }) }, async rename(request) { @@ -1214,36 +1241,40 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const agent = found.agent // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). const source: MessageSource = { kind: 'user', rpcId: request.rpcId } - try { - if (content.some(part => part.type === 'image')) { - const target = targetFor(agent).current - const provider = target.provider - const model = target.model - const modelInfo = await ctx.llm.resolveModelInfo(provider, model) - if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) { + const hasImage = content.some(part => part.type === 'image') + const admit = async (): Promise> => { + try { + if (hasImage) { + const target = targetFor(agent).current + const provider = target.provider + const model = target.model + const modelInfo = await ctx.llm.resolveModelInfo(provider, model) + if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) { + return err(request, { + code: 'attachment-error', + message: `Model "${model}" does not support image input.`, + details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, + }) + } + } + const durable = await durablePromptContent(ctx, content) + const message: UserMessage = createUserMessage({ content: durable, source }) + if (mode === 'steer') agent.steer(message) + else agent.followup(message) + } catch (error: unknown) { + if (error instanceof AttachmentError) { return err(request, { code: 'attachment-error', - message: `Model "${model}" does not support image input.`, - details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, + message: error.message, + details: { reason: error.code }, }) } + // A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached. + return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } }) } - const durable = await durablePromptContent(ctx, content) - const message: UserMessage = createUserMessage({ content: durable, source }) - if (mode === 'steer') agent.steer(message) - else agent.followup(message) - } catch (error: unknown) { - if (error instanceof AttachmentError) { - return err(request, { - code: 'attachment-error', - message: error.message, - details: { reason: error.code }, - }) - } - // A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached. - return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } }) + return ok(request, { accepted: true as const }) } - return ok(request, { accepted: true as const }) + return hasImage ? serializeImageAdmission(agent, admit) : admit() }, async attachment(request) { diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index ad70e6b26a..c409eff423 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -302,7 +302,7 @@ describe('session/queued frames', () => { expect(replayFrames.filter(f => f.type === 'session/queued')).toEqual(liveFrames) }) - it('retires mirror entries on their terminal dequeue', async () => { + it('retains each dequeued entry until its durable message publishes', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) const agent = stubAgent(ctx) @@ -311,15 +311,50 @@ describe('session/queued frames', () => { ctx.emit('agent/inbox/enqueue', agent, queued, 'queued') ctx.emit('agent/inbox/enqueue', agent, steering, 'steering') ctx.emit('agent/inbox/dequeue', agent, queued, 'queued') - ctx.emit('agent/inbox/dequeue', agent, steering, 'steering') + const pendingAbort = new AbortController() + const pending = await collect( + api.events.mux({ rpcId: RpcId('t-mux-dequeued'), payload: {} }, pendingAbort.signal), 3, pendingAbort) + expect(pending.filter(f => f.type === 'session/queued')).toEqual([ + { type: 'session/queued', sessionId: agent.id, message: queued, steering: false }, + { type: 'session/queued', sessionId: agent.id, message: steering, steering: true }, + ]) + + agent.session.append('user/message', queued, { surfaceOp: 'append' }) + ctx.emit('agent/inbox/dequeue', agent, steering, 'steering') + agent.session.append('steering/message', { turn: 1, message: steering }, { surfaceOp: 'append' }) const abort = new AbortController() const frames = await collect( api.events.mux({ rpcId: RpcId('t-mux-after'), payload: {} }, abort.signal), 1, abort) expect(frames.filter(f => f.type === 'session/queued')).toHaveLength(0) }) - it('retires the matching placement when one message identity is queued and steering', async () => { + it('retires claimed entries whose admission ends without publication', async () => { + const ctx = await harness() + const api = createApiProxy(ctx, DEFAULTS) + const agent = stubAgent(ctx) + const rejected = inboxMessage('m-rejected', 'rejected') + const successor = inboxMessage('m-successor', 'successor') + ctx.emit('agent/inbox/enqueue', agent, rejected, 'queued') + ctx.emit('agent/inbox/dequeue', agent, rejected, 'queued') + ctx.emit('agent/inbox/enqueue', agent, successor, 'queued') + ctx.emit('agent/inbox/dequeue', agent, successor, 'queued') + + const pendingAbort = new AbortController() + const pending = await collect( + api.events.mux({ rpcId: RpcId('t-mux-rejected'), payload: {} }, pendingAbort.signal), 2, pendingAbort) + expect(pending.filter(f => f.type === 'session/queued')).toEqual([ + { type: 'session/queued', sessionId: agent.id, message: successor, steering: false }, + ]) + + ctx.emit('agent/status', agent, 'idle') + const idleAbort = new AbortController() + const idle = await collect( + api.events.mux({ rpcId: RpcId('t-mux-rejected-idle'), payload: {} }, idleAbort.signal), 1, idleAbort) + expect(idle.filter(f => f.type === 'session/queued')).toHaveLength(0) + }) + + it('retires the matching published placement when one message identity is queued and steering', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) const agent = stubAgent(ctx) @@ -328,6 +363,7 @@ describe('session/queued frames', () => { ctx.emit('agent/inbox/enqueue', agent, repeated, 'steering') ctx.emit('agent/inbox/dequeue', agent, inboxMessage('unknown', 'not queued'), 'queued') ctx.emit('agent/inbox/dequeue', agent, repeated, 'steering') + agent.session.append('steering/message', { turn: 1, message: repeated }, { surfaceOp: 'append' }) const abort = new AbortController() const frames = await collect( diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 10b3e96e47..450d9cbcf9 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -117,10 +117,26 @@ function expectValue(response: { result: { ok: true; value: T } | { ok: false return response.result.value } +function registerTextOnly(ctx: Context): void { + ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter { + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) + } + }('Text Only', [])) +} + describe('Web session model selection', () => { it('accepts ordered multi-image prompts and rejects configured batch-limit excess before persistence', async () => { const { ctx, agent, sessionId } = await harness() - const validateImage = vi.fn((_input: { data: Uint8Array }): void => {}) + let secondValidationStarted!: () => void + let releaseSecondValidation!: () => void + const secondStarted = new Promise((resolve) => { secondValidationStarted = resolve }) + const secondReleased = new Promise((resolve) => { releaseSecondValidation = resolve }) + const validateImage = vi.fn(async (input: { data: Uint8Array }): Promise => { + if (input.data[0] !== 2) return + secondValidationStarted() + await secondReleased + }) const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => { return Promise.resolve({ attachmentId: `att-${String(input.data[0])}`, @@ -141,11 +157,15 @@ describe('Web session model selection', () => { const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) const first = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' } const second = { type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==', name: 'second.png' } - const accepted = await api.sessions.prompt(request({ + const accepting = api.sessions.prompt(request({ sessionId, mode: 'queue' as const, content: [first, { type: 'text' as const, text: 'compare' }, second], })) + await secondStarted + expect(saveImage).not.toHaveBeenCalled() + releaseSecondValidation() + const accepted = await accepting expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } }) expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]]) expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]]) @@ -294,13 +314,9 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('refuses a text-only selection once the session log carries an image', async () => { + it('refuses a text-only selection while current derived history carries an image', async () => { const { ctx, sessionId, agent } = await harness() - ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter { - override resolveModel(provider: string, model: string): Promise { - return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) - } - }('Text Only', [])) + registerTextOnly(ctx) ctx.llm.registerAdapter(['vision'], new class extends CatalogAdapter { override resolveModel(provider: string, model: string): Promise { return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text', 'image'] }) @@ -318,7 +334,7 @@ describe('Web session model selection', () => { content: [{ type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }], } as never, { surfaceOp: 'append' }) - // The log is immutable: a text-only route would fail every later turn. + // The image remains on the current request surface, so a text-only route would fail the next turn. const stranded = await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain', })) @@ -337,31 +353,87 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('refuses a text-only selection while an image prompt is still queued (not yet logged)', async () => { + it('keeps a dequeued image pending until publication, then follows the compacted surface', async () => { const { ctx, sessionId, agent } = await harness() - ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter { - override resolveModel(provider: string, model: string): Promise { - return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) - } - }('Text Only', [])) + registerTextOnly(ctx) const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) - // The queued message enters the session log only when claimed — after a - // model switch would already have landed. The pending-inbox mirror must - // therefore gate the switch too. - ctx.emit('agent/inbox/enqueue', agent, { + const queued = { id: 'q-1', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', attachment: { attachmentId: 'att-q', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }], - } as never, 'queued') - const stranded = await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' })) - expect(stranded.result.ok).toBe(false) - // Claiming the message drains the mirror; the log now owns the decision. - ctx.emit('agent/inbox/dequeue', agent, { id: 'q-1' } as never, 'queued') + } as never + ctx.emit('agent/inbox/enqueue', agent, queued, 'queued') + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + + // Dequeue precedes the authoritative append, so it cannot open a switch window. + ctx.emit('agent/inbox/dequeue', agent, queued, 'queued') + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + + const imageEvent = agent.session.append('user/message', queued, { surfaceOp: 'append' }) + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + + // Publication retires the mirror; once compaction shadows the image, the + // current model-visible surface no longer requires an image-capable route. + agent.session.append('user/message', { + id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' }, + content: [{ type: 'text', text: 'image summarized' }], + } as never, { + surfaceOp: { op: 'replace', start: imageEvent.seq, end: imageEvent.seq }, + sourceEventSeqs: [imageEvent.seq], + }) expect(expectValue(await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain', }))).selected).toEqual({ provider: 'text-only', model: 'plain' }) await ctx.fiber.dispose() }) + it('serializes an image save with a concurrent model selection', async () => { + const { ctx, sessionId, agent } = await harness() + registerTextOnly(ctx) + let saveStarted!: () => void + let releaseSave!: () => void + const started = new Promise((resolve) => { saveStarted = resolve }) + const released = new Promise((resolve) => { releaseSave = resolve }) + const ref = { attachmentId: 'att-race', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 } + ctx.provide('attachments', { + imageLimits: { + maxImageBytes: 1, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1, + maxImagePixels: 1, + mediaTypes: ['image/png'], + }, + validateImage: () => Promise.resolve(), + saveImage: async () => { + saveStarted() + await released + return ref + }, + } as never) + Object.assign(agent, { + followup(message: UserMessage) { + ctx.emit('agent/inbox/enqueue', agent, message, 'queued') + }, + }) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const prompt = api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AA==' }], + })) + await started + const selection = api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' })) + expect(await Promise.race([ + selection.then(() => 'settled' as const), + new Promise<'pending'>((resolve) => { setTimeout(() => { resolve('pending') }, 0) }), + ])).toBe('pending') + + releaseSave() + expect((await prompt).result.ok).toBe(true) + expect((await selection).result.ok).toBe(false) + await ctx.fiber.dispose() + }) + it('authorizes an attachment read referenced only from wrapped message content', async () => { const { ctx, sessionId, agent } = await harness() const ref = { attachmentId: 'att-w', mediaType: 'image/png' as const, bytes: 4, width: 1, height: 1 } @@ -369,9 +441,8 @@ describe('Web session model selection', () => { readImage: () => Promise.resolve({ ref, data: new Uint8Array([1, 2, 3, 4]) }), } as never) const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) - // The only reference lives inside an assistant/message wrapper — the same - // walk that gates model selection must authorize the read, or a real host - // denies galleries the fixture (with its own authorization mirror) serves. + // The only reference lives inside an assistant/message wrapper; the + // authorization walk must follow that durable event shape. agent.session.append('assistant/message', { turn: 1, step: 0, message: { id: 'a-1', role: 'assistant', source: { kind: 'model', provider: 'p', model: 'm' }, content: [{ type: 'image', attachment: ref }] }, @@ -383,7 +454,7 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('detects images on every replayed route: wrapped messages, streamed blocks, nested tool results', async () => { + it('detects images in wrapped messages and nested tool results on the current surface', async () => { const image = { type: 'image', attachment: { attachmentId: 'att-x', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } } const cases: { label: string; append: (agent: Agent) => void }[] = [ { @@ -394,14 +465,6 @@ describe('Web session model selection', () => { } as never, { surfaceOp: 'append' }) }, }, - { - label: 'streamed assistant block', - append: (agent) => { - agent.session.append('assistant/chunk', { - turn: 1, step: 0, chunk: { type: 'block-end', index: 0, block: image }, - } as never) - }, - }, { label: 'nested tool-result content', append: (agent) => { @@ -414,11 +477,7 @@ describe('Web session model selection', () => { ] for (const { label, append } of cases) { const { ctx, sessionId, agent } = await harness() - ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter { - override resolveModel(provider: string, model: string): Promise { - return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) - } - }('Text Only', [])) + registerTextOnly(ctx) const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) append(agent) const stranded = await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' })) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 3c6c7729c4..394e12a772 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: 3cd64b8170ac0f6b6c4316f19bb816c65c223935 -README.zh.md: 478ccb91df996aaf67bd952e95596f4ea65564bc +README.md: 885a2dcbc6fea3c21421d83202941f8251bc3c06 +README.zh.md: d5ea4b80bdc2e0655994667cbd099af7d20aefe9 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 3cd64b8170..885a2dcbc6 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -45,7 +45,7 @@ Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reason The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. -Image requests resolve the optional `ctx.attachments` service when the request is dispatched, so Cordis plugin load order does not freeze attachment availability. A visual request still fails explicitly with `UNSUPPORTED_CONTENT` when the service or the selected model's image capability is absent. +Image requests resolve the optional `ctx.attachments` service when the request is dispatched, so Cordis plugin load order does not freeze attachment availability. Image detection and conversion recurse through nested `tool-result` content, so a nested image is neither flattened nor skipped. A visual request still fails explicitly with `UNSUPPORTED_CONTENT` when the service or the selected model's image capability is absent. ## Provider/model routing and replay diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 478ccb91df..d5ea4b80bd 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -45,7 +45,7 @@ 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 -图片请求会在请求分发时解析可选的 `ctx.attachments` 服务,因此 Cordis 插件加载顺序不会固化附件可用性。当该服务或所选模型的图片能力不存在时,视觉请求仍会明确以 `UNSUPPORTED_CONTENT` 失败。 +图片请求会在请求分发时解析可选的 `ctx.attachments` 服务,因此 Cordis 插件加载顺序不会固化附件可用性。图片检测与转换会递归遍历嵌套的 `tool-result` 内容,因此嵌套图片既不会被展平,也不会被跳过。当该服务或所选模型的图片能力不存在时,视觉请求仍会明确以 `UNSUPPORTED_CONTENT` 失败。 ## 提供方/模型路由与回放 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 2a4b08e607..3b5bb9b9cb 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -33,7 +33,7 @@ import type { import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import { resolveProfiles } from './config.ts' import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' -import { toPiContext } from './context.ts' +import { contentHasImage, toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' /** Constructor options for {@link PiAiAdapter}. */ @@ -192,8 +192,7 @@ export class PiAiAdapter extends LlmAdapter { const containsImage = options.messages.some((message) => { // The discriminant is part of same-process message validity and is read before content. void message.role - return message.content.some(block => block.type === 'image' - || (block.type === 'tool-result' && block.content.some(piece => piece.type === 'image'))) + return contentHasImage(message.content) }) if (containsImage && !model.input.includes('image')) { throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT') diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 90d2176b1c..b1464d7289 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -18,6 +18,23 @@ function flattenText(message: Message): string { .join('') } +/** + * Return whether content contains an image, including nested tool results. + * @param blocks - content to inspect recursively. + * @returns whether any nested block is an image. + */ +export function contentHasImage(blocks: readonly ContentBlock[]): boolean { + return blocks.some(block => block.type === 'image' + || (block.type === 'tool-result' && contentHasImage(block.content))) +} + +/** Flatten text recursively inside one tool result. */ +function toolResultText(blocks: readonly ContentBlock[]): string { + return blocks.map(block => block.type === 'text' + ? block.text + : block.type === 'tool-result' ? toolResultText(block.content) : '').join('') +} + async function userContent( blocks: readonly ContentBlock[], attachments: AttachmentStore, @@ -38,6 +55,14 @@ async function userContent( break } case 'tool-result': + { + const nested = await userContent(block.content, attachments) + if (typeof nested === 'string') { + if (nested.length > 0) content.push({ type: 'text', text: nested }) + } else { + content.push(...nested) + } + } break default: // Other merge-extensible blocks are not user-input vocabulary for pi-ai. @@ -72,8 +97,7 @@ function textOnlyContext(options: GenerateOptions): PiContext { const toolNames = new Map() const messages: PiMessage[] = [] for (const message of options.messages) { - if (message.content.some(block => block.type === 'image' - || (block.type === 'tool-result' && block.content.some(piece => piece.type === 'image')))) { + if (contentHasImage(message.content)) { throw new LlmError('pi-ai image conversion requires the durable attachment service', 'UNSUPPORTED_CONTENT') } if (message.role === 'system') { @@ -96,7 +120,7 @@ function textOnlyContext(options: GenerateOptions): PiContext { toolName: toolNames.get(result.toolCallId) ?? 'unknown', content: [{ type: 'text', - text: result.content.filter(block => block.type === 'text').map(block => block.text).join('') || '(no output)', + text: toolResultText(result.content) || '(no output)', }], isError: result.isError ?? false, timestamp: 0, @@ -131,7 +155,7 @@ async function toPiContextWithImages(options: GenerateOptions, attachments: Atta for (const message of options.messages) { if (message.role === 'system') { - if (message.content.some(block => block.type === 'image')) { + if (contentHasImage(message.content)) { throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT') } // pi-ai has a single systemPrompt slot; in-history system messages are diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 272135bef9..cde449d34a 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -256,8 +256,8 @@ describe('PiAiAdapter provider routing', () => { mediaTypes: ['image/png'], } - validateImage(_input: SaveImageAttachment): void { - throw new Error('not used') + validateImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('not used')) } saveImage(_input: SaveImageAttachment): Promise { @@ -613,8 +613,12 @@ describe('provider profile lifecycle', () => { messages: [createUserMessage({ content: [{ type: 'tool-result', - toolCallId: 'call-image' as never, - content: [{ type: 'image', attachment: IMAGE_REF }], + toolCallId: 'call-outer' as never, + content: [{ + type: 'tool-result', + toolCallId: 'call-inner' as never, + content: [{ type: 'image', attachment: IMAGE_REF }], + }], }], source: { kind: 'plugin', plugin: 'test' }, })], diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index f57cacd13a..f6d55e39da 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -97,6 +97,55 @@ describe('toPiContext', () => { }) }) + it('flattens nested tool-result images into the enclosing result', async () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 3, + width: 1, + height: 1, + } + const readImage = vi.fn().mockResolvedValue({ ref: attachment, data: Uint8Array.of(1, 2, 3) }) + const context = await toPiContext({ + provider: 'openai', + model: 'gpt-4.1', + messages: [createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId('outer'), + content: [ + { type: 'tool-result', toolCallId: CallId('empty'), content: [] }, + { type: 'text', text: 'before' }, + { type: 'tool-result', toolCallId: CallId('text'), content: [{ type: 'text', text: 'middle' }] }, + { + type: 'tool-result', + toolCallId: CallId('inner'), + content: [ + { type: 'image', attachment }, + { type: 'text', text: 'after' }, + ], + }, + ], + }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }, { readImage } as unknown as AttachmentStore) + + expect(context.messages).toEqual([{ + role: 'toolResult', + toolCallId: 'outer', + toolName: 'unknown', + content: [ + { type: 'text', text: 'before' }, + { type: 'text', text: 'middle' }, + { type: 'image', data: 'AQID', mimeType: 'image/png' }, + { type: 'text', text: 'after' }, + ], + isError: false, + timestamp: 0, + }]) + }) + it('rejects structured image history when no durable resolver is supplied', () => { expect(() => toPiContext({ provider: 'openai', model: 'gpt-4.1', @@ -205,7 +254,15 @@ describe('toPiContext', () => { source: { kind: 'plugin', plugin: 'test' }, }), createUserMessage({ - content: [{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'Sunny' }] }], + content: [{ + type: 'tool-result', + toolCallId: CallId('c1'), + content: [ + { type: 'text', text: 'Sunny' }, + { type: 'tool-result', toolCallId: CallId('nested'), content: [{ type: 'text', text: '!' }] }, + { type: 'chart', data: 'ignored' } as unknown as ContentBlock, + ], + }], source: { kind: 'plugin', plugin: 'test' }, }), ], @@ -214,7 +271,7 @@ describe('toPiContext', () => { role: 'toolResult', toolCallId: 'c1', toolName: 'get_weather', - content: [{ type: 'text', text: 'Sunny' }], + content: [{ type: 'text', text: 'Sunny!' }], isError: false, timestamp: 0, }) diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 43bd0833d4..fbc39c2dac 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -74,8 +74,8 @@ async function harness(image?: StoredImageAttachment): Promise { mediaTypes: [fixture.ref.mediaType], } - validateImage(_input: SaveImageAttachment): void { - throw new Error('e2e attachment fixture is read-only') + validateImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('e2e attachment fixture is read-only')) } saveImage(_input: SaveImageAttachment): Promise { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1515b247c..c135d13219 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -708,6 +708,9 @@ importers: schemastery: specifier: ^3.18.0 version: 3.18.0 + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@22.20.0) devDependencies: '@deepseek-ai/dsh-attachment': specifier: workspace:^ @@ -1092,6 +1095,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -6457,6 +6463,9 @@ packages: '@emnapi/runtime@1.11.0': resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -6996,6 +7005,168 @@ packages: '@iconify/utils@3.1.3': resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -10420,6 +10591,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -10434,6 +10610,15 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -11767,6 +11952,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -12079,6 +12269,112 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-win32-arm64@0.35.3': + optional: true + + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': + optional: true + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -15822,6 +16118,8 @@ snapshots: semver@7.8.4: {} + semver@7.8.5: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -15851,6 +16149,39 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.35.3(@types/node@22.20.0): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 22.20.0 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index c843f94a5a..2ee623c606 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -96,8 +96,8 @@ class TestAttachmentStore extends AttachmentStore { mediaTypes: ['image/png'], } - validateImage(_input: SaveImageAttachment): void { - throw new Error('test invariant attachment store does not validate images') + validateImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('test invariant attachment store does not validate images')) } saveImage(_input: SaveImageAttachment): Promise { From da039fcb0d653064c9c0b1e74214db543d089e33 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:09:51 +0800 Subject: [PATCH 020/229] test: complete image admission gate coverage --- docs/module-graph.md | 3 ++- packages/attachment/attachment-local/tests/index.spec.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index bcdf0a87e7..cee3f96342 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -374,6 +374,7 @@ flowchart TD pkg_web --> pkg_invariants pkg_web --> pkg_llm pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -1043,7 +1044,7 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 7ea71d166b..8aad68d2ff 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -48,6 +48,9 @@ describe('local attachment service', () => { 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', )) + const limited = new LocalAttachmentStore(new Context(), { dshHome, maxImageBytes: 1 }) + await expect(limited.validateImage({ data: valid, mediaType: 'image/png' })) + .rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) await expect(service.validateImage({ data: valid, mediaType: 'image/png' })).resolves.toBeUndefined() expect(existsSync(service.root)).toBe(false) } finally { From 0d1250f743b709be080f05757d4705eee8c7b626 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 14:34:08 +0800 Subject: [PATCH 021/229] fix: address ds-review-bot v7 findings on the merged image-input head - gate model selection on steering-placement image carriers from enqueue until their steering/message event publishes; release the gate when an admission ends idle without publication (both behaviorally asserted) - reject session.updateQueue edits carrying non-text blocks at the RPC boundary (queue edits cannot bypass image admission) - extend the durable-directory walk past a first-created DSH_HOME to the deepest pre-existing ancestor - strip Windows-style separators from attachment display names on POSIX - verify attachment reads with a header-only probe (digest already proves the bytes decoded fully at admission); document the read path - make SessionInputShell.addImages refusal observable and keep workspace transfers/composer intake from leaking refused drafts - own ONE recursive image walk (dsh-llm contentHasImage) across apiproxy, pi-ai, compact-basic, and the DeepSeek text-only assertion - drop the redundant canonical-base64 regex and the no-op role read - move AttachmentId/AttachmentError out of types.ts (brand.ts/error.ts); document why AttachmentError does not extend HarnessError - document the hard attachments inject in both consumer READMEs --- ...07-29-atomic-web-image-admission.i18n.yaml | 4 +- .../2026-07-29-atomic-web-image-admission.md | 2 +- ...026-07-29-atomic-web-image-admission.zh.md | 2 +- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 4 +- ...-image-input-and-durable-attachments.zh.md | 4 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 4 +- docs/event-producer-consumer.md | 2 +- .../attachment/attachment-local/src/image.ts | 55 +++++++++++----- .../attachment/attachment-local/src/store.ts | 51 ++++++++++++--- packages/attachment/attachment/src/brand.ts | 15 +++++ packages/attachment/attachment/src/error.ts | 26 ++++++++ packages/attachment/attachment/src/index.ts | 3 +- packages/attachment/attachment/src/types.ts | 31 +-------- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 1 + packages/client/connection/README.zh.md | 1 + .../ui-conversation/src/client/apply.ts | 13 +++- .../src/client/input/contract.ts | 15 +++-- .../src/client/input/facade.ts | 13 ++-- .../ui-conversation/tests/chat-view.spec.tsx | 2 +- .../tests/gate-branch-tails.spec.tsx | 4 +- .../ui-conversation/tests/input-bar.spec.tsx | 20 ++++++ .../tests/terminal-card.spec.tsx | 4 +- .../compact/compact-basic/src/summarizer.ts | 10 +-- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 1 + packages/host/apiproxy/README.zh.md | 1 + packages/host/apiproxy/src/api-proxy.ts | 59 +++++++++++------ .../apiproxy/tests/api-proxy-models.spec.ts | 64 +++++++++++++++++++ packages/llm/llm-deepseek/src/serialize.ts | 9 +-- packages/llm/llm-pi-ai/src/adapter.ts | 9 +-- packages/llm/llm-pi-ai/src/context.ts | 11 +--- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 4 +- packages/llm/llm/src/content.ts | 16 +++++ packages/llm/llm/src/index.ts | 1 + 37 files changed, 335 insertions(+), 140 deletions(-) create mode 100644 packages/attachment/attachment/src/brand.ts create mode 100644 packages/attachment/attachment/src/error.ts create mode 100644 packages/llm/llm/src/content.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml index 08398b5440..d5c2e38a49 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md -2026-07-29-atomic-web-image-admission.md: 7b2f7aeb43ca1393cdab3abe35916964bc47f0c9 -2026-07-29-atomic-web-image-admission.zh.md: 5a04d2e599744dfe9f92eb64a6c828161c46bf6e +2026-07-29-atomic-web-image-admission.md: c09d376f101a41994df3a10c22c06da4e59f06f6 +2026-07-29-atomic-web-image-admission.zh.md: 8785f7489b0c433cba43a1747533b1d38aada3d3 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md index 7b2f7aeb43..c09d376f10 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md @@ -12,7 +12,7 @@ Image prompt admission and `session.selectModel` each read session modality stat Each live Web agent has one private promise chain shared by image-bearing prompt admission and model selection. A failed operation settles its caller normally and leaves the chain usable. Text-only prompts bypass the chain because they cannot change the modality constraint. -The pending-inbox mirror marks a prompt as claimed at dequeue and retains it until the matching `user/message` or `steering/message` event publishes. If admission ends without publishing, the next dequeue or the transition to idle retires the claimed entry; inbox discard retires the listed work, and session disposal retires every remaining entry. Model selection checks that mirror plus `Session.deriveMessages()`, which is the current model-visible history after compaction. +The pending-publication set records a queued occurrence at dequeue and a steering occurrence already at enqueue (steering items never enter the queued UI mirror), and retains each until its matching `user/message` or `steering/message` event publishes. If admission ends without publishing, the transition to idle retires the entries; inbox discard retires the listed work, and session disposal retires every remaining entry. Model selection checks that set, the queued UI mirror, and `Session.deriveMessages()`, which is the current model-visible history after compaction. Provider adapters remain the final enforcement boundary. The host ordering only prevents its mutable route and pending image state from contradicting each other before request assembly. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md index 5a04d2e599..8785f7489b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md @@ -12,7 +12,7 @@ Status: implemented 每个活跃 Web agent(智能体)都有一条私有 promise 链,由包含图片的提示词准入与模型选择共享。操作失败会照常传递给调用方,且不会使该链失效。纯文本提示词绕过该链,因为它们不会改变模态约束。 -待处理 inbox 镜像会在提示词出队时将其标记为已认领,并保留到匹配的 `user/message` 或 `steering/message` 事件发布。若准入结束时未发布事件,下一次出队或转为空闲状态会移除已认领的条目;inbox 丢弃会移除列出的工作项,会话 dispose(资源释放)则会移除所有剩余条目。模型选择会检查该镜像以及 `Session.deriveMessages()`;后者表示压缩后模型当前可见的历史。 +待发布集合会在排队条目出队时记录它,而 steering 条目在入队时即被记录(steering 条目从不进入排队 UI 镜像),并各自保留到匹配的 `user/message` 或 `steering/message` 事件发布。若准入结束时未发布事件,转为空闲状态会移除这些条目;inbox 丢弃会移除列出的工作项,会话 dispose(资源释放)则会移除所有剩余条目。模型选择会检查该集合、排队 UI 镜像以及 `Session.deriveMessages()`;后者表示压缩后模型当前可见的历史。 提供方适配器仍是最终的强制检查边界。宿主的顺序控制仅用于避免其可变路由与待发布图片状态在请求组装前彼此矛盾。 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 04783dff62..dbf7d9ed1f 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 50ba92ed503126fc26859d7646774a5b25bcc4eb -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 93fff2a550fdcfe013110eab28ddba0a38ec7490 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 1b0e9083e205bb69f8a3ee9e4c073af7864b6ed7 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 095420b09ea34e98002480a218f74bc9f8421876 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 50ba92ed50..1b0e9083e2 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -122,7 +122,7 @@ Base64 crosses JSON-RPC once and is discarded after persistence. The host valida Model catalog entries gain optional merge-extensible input modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. -The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)). Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history; compaction can remove old images and make a later text-only selection valid. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. +The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history; compaction can remove old images and make a later text-only selection valid, and an admission that ends idle without publication releases the gate. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. @@ -196,7 +196,7 @@ UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalog ## Testing - Storage tests cover content-addressed deduplication, private permissions, admission failures, corruption/missing-object failures, and reading history after deployment limits are lowered. -- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, image-admission/model-selection races, pending publication, and selection against current derived history after compaction. +- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, image-admission/model-selection races (queued and steering placements), pending publication, idle release without publication, text-only queue edits, and selection against current derived history after compaction. - Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, draft/session-scope/application object-URL cleanup, and a deferred historical read that completes after disposal; the keyless assembled built-client lane (`apps/web/tests/image-display.snapshot.ts`, `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail. - Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, recursively nested tool-result images, preserved summary input, and explicit image-output rejection. - A credentialed real-API test sends a PNG through the Anthropic `claude-opus-4-8` route and requires the model to identify its QR code. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index 93fff2a550..095420b09e 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -122,7 +122,7 @@ Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规 模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md))。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标;压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标;压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效,而未发布任何事件即转入空闲的准入会释放该门槛。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 @@ -196,7 +196,7 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模 ## 测试 - 存储测试覆盖内容寻址去重、私有权限、准入失败、对象损坏或缺失时的失败,以及收紧部署限制后读取历史数据。 -- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的竞态、待发布状态,以及压缩后依据当前派生历史进行的选择。 +- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的竞态(排队与 steering 两种放置)、待发布状态、未发布即空闲时的门槛释放、仅文本的队列编辑,以及压缩后依据当前派生历史进行的选择。 - 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序、草稿、会话作用域和应用层级的对象 URL 清理,以及一项在释放后才完成的延迟历史读取;keyless 的组装后构建产物通道(`apps/web/tests/image-display.snapshot.ts`,`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。 - 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、递归嵌套在工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。 - 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG,并要求模型识别其中的二维码。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 8e16c83c64..63c6e02fc2 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -567,7 +567,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:59`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:60`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ecb94d05bc..55d0c38c70 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -285,7 +285,7 @@ abstract readImage(ref: ImageAttachmentRef): Promise Types: [ImageAttachmentRef](../core-data-structures/attachment.md) · [SaveImageAttachment](../core-data-structures/attachment.md) · [StoredImageAttachment](../core-data-structures/attachment.md) -Source: [`packages/attachment/attachment/src/index.ts:28`](../../packages/attachment/attachment/src/index.ts) +Source: [`packages/attachment/attachment/src/index.ts:29`](../../packages/attachment/attachment/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -852,7 +852,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:192`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:193`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4af0d43cf1..b3513add9a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -31,7 +31,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:59`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:60`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | diff --git a/packages/attachment/attachment-local/src/image.ts b/packages/attachment/attachment-local/src/image.ts index 1ef30358a2..e06bf459df 100644 --- a/packages/attachment/attachment-local/src/image.ts +++ b/packages/attachment/attachment-local/src/image.ts @@ -1,6 +1,6 @@ -/** Raster decoding used before bytes enter durable storage. */ +/** Raster inspection: full decode at admission, header-only probe on verified reads. */ -import sharp from 'sharp' +import sharp, { type Sharp } from 'sharp' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' @@ -18,26 +18,47 @@ const MEDIA_TYPES: Readonly> = { gif: 'image/gif', } +async function imageMetadata(image: Sharp): Promise { + const metadata = await image.metadata() + const mediaType = MEDIA_TYPES[metadata.format as string] + if (mediaType === undefined) { + throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE') + } + return { mediaType, width: metadata.width, height: metadata.height } +} + /** - * Decode a supported raster and return its intrinsic metadata. + * Parse a supported raster's header and return its intrinsic metadata without + * decoding pixels. Digest-verified reads use this: admission already proved + * that these exact bytes decode completely, so the read path only re-derives + * the reference fields instead of paying the full-raster decode again. * @param data - complete encoded image bytes. - * @param maxPixels - optional write-time decoded-pixel limit; reads omit it. * @returns verified format and dimensions. */ -export async function detectImage(data: Uint8Array, maxPixels?: number): Promise { +export async function probeImage(data: Uint8Array): Promise { try { - const image = sharp(data, { failOn: 'error', limitInputPixels: false }) - const metadata = await image.metadata() - const mediaType = MEDIA_TYPES[metadata.format as string] - if (mediaType === undefined) { - throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE') - } - const { width, height } = metadata - if (maxPixels !== undefined && width * height > maxPixels) { - throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS') - } - await image.raw().toBuffer() - return { mediaType, width, height } + return await imageMetadata(sharp(data, { failOn: 'error', limitInputPixels: false })) + } catch (error) { + if (error instanceof AttachmentError) throw error + throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error }) + } +} + +/** + * Fully decode a supported raster and return its intrinsic metadata. + * @param data - complete encoded image bytes. + * @param maxPixels - decoded-pixel admission limit. + * @returns verified format and dimensions. + */ +export async function detectImage(data: Uint8Array, maxPixels?: number): Promise { + try { + const image = sharp(data, { failOn: 'error', limitInputPixels: false }) + const detected = await imageMetadata(image) + if (maxPixels !== undefined && detected.width * detected.height > maxPixels) { + throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS') + } + await image.raw().toBuffer() + return detected } catch (error) { if (error instanceof AttachmentError) throw error throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error }) diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index f489ae315c..809cbfe53d 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -2,8 +2,8 @@ import { createHash, randomUUID } from 'node:crypto' import { constants } from 'node:fs' -import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises' -import { basename, dirname, join, resolve } from 'node:path' +import { chmod, link, mkdir, open, readFile, stat, unlink } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' import { AttachmentError, AttachmentId, @@ -14,7 +14,7 @@ import type { SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { detectImage } from './image.ts' +import { detectImage, probeImage } from './image.ts' const ID_PATTERN = /^sha256:([a-f0-9]{64})$/ @@ -24,7 +24,11 @@ function digest(data: Uint8Array): string { function displayName(value: string | undefined): string | undefined { if (value === undefined) return undefined - const clean = basename(value).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255) + // Strip both separator styles by hand: a POSIX host treats `\` as an + // ordinary character, so path.basename would keep a Windows client's full + // local path and leak it into the reference and the session log. + const leaf = value.slice(Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + 1) + const clean = leaf.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255) return clean === '' ? undefined : clean } @@ -79,6 +83,31 @@ async function syncDirectory(path: string): Promise { } } +/** + * Walk up from a preferred boundary to the deepest ancestor that already + * exists. A first save may create DSH_HOME itself (recursive mkdir), and a + * directory this process creates is not durable until its parent entry syncs + * — so only a pre-existing directory may be vouched as the durable stop. + * @param path - preferred absolute boundary. + * @returns `path` when it exists, else its closest existing ancestor. + */ +async function existingBoundary(path: string): Promise { + let level = resolve(path) + while (true) { + try { + await stat(level) + return level + } catch { + // Swallows only the stat probe's failure: a missing (or unreadable) + // level simply moves the boundary up; mkdir later surfaces real errors. + } + const parent = dirname(level) + /* v8 ignore next -- filesystem-root guard: the root directory always exists, so stat returns first. */ + if (parent === level) return level + level = parent + } +} + /** * Create one private directory tree and persist every ancestor entry up to a * caller-vouched durable boundary. The walk deliberately ignores what mkdir @@ -118,10 +147,12 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li const sha256 = digest(input.data) const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') - // The durable boundary is the root's grandparent (DSH_HOME for the + // The preferred boundary is the root's grandparent (DSH_HOME for the // documented `DSH_HOME/attachments/v1` layout): `attachments`/`v1` may be // first-created by a concurrent save, so their entries sync on every path. - const boundary = dirname(dirname(resolve(root))) + // When DSH_HOME itself does not exist yet, the boundary retreats to its + // closest existing ancestor so the first save syncs the new home entry too. + const boundary = await existingBoundary(dirname(dirname(resolve(root)))) await ensureDurableDirectory(bucket, boundary) await ensureDurableDirectory(staging, boundary) const temporary = join(staging, randomUUID()) @@ -188,8 +219,12 @@ export async function readImageFile(root: string, ref: ImageAttachmentRef): Prom throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error }) } if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') - const metadata = await inspectMetadata(data, ref.mediaType) - if (metadata.bytes !== ref.bytes || metadata.width !== ref.width || metadata.height !== ref.height) { + // The digest proves these are the exact bytes admission fully decoded, so + // the read path only re-derives the header fields (no raster decode, no + // per-request pixel amplification on history replay). + const metadata = await probeImage(data) + if (metadata.mediaType !== ref.mediaType || data.byteLength !== ref.bytes + || metadata.width !== ref.width || metadata.height !== ref.height) { throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT') } return { ref, data } diff --git a/packages/attachment/attachment/src/brand.ts b/packages/attachment/attachment/src/brand.ts new file mode 100644 index 0000000000..6df4014f74 --- /dev/null +++ b/packages/attachment/attachment/src/brand.ts @@ -0,0 +1,15 @@ +/** Attachment identifier brand. @module @deepseek-ai/dsh-attachment/brand */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Opaque content-addressed identifier for one immutable attachment object. */ +export type AttachmentId = Branded<'AttachmentId'> + +/** + * Brand a validated storage identifier. + * @param value - backend-produced opaque identifier. + * @returns the branded identifier. + */ +export function AttachmentId(value: string): AttachmentId { + return value as AttachmentId +} diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts new file mode 100644 index 0000000000..827d77f58a --- /dev/null +++ b/packages/attachment/attachment/src/error.ts @@ -0,0 +1,26 @@ +/** Attachment failure class. @module @deepseek-ai/dsh-attachment/error */ + +/** + * Stable failures suitable for host RPC error mapping. + * + * Deliberately re-implements the `HarnessError` shape instead of extending it: + * the base lives in `@deepseek-ai/dsh-llm`, which itself depends on this + * package (`ImageBlock` references `ImageAttachmentRef`), so sharing the base + * would create a dependency cycle. Consumers route on `code`, never on the + * prototype chain, so the shapes stay interchangeable at the wire boundary. + */ +export class AttachmentError extends Error { + /** Stable machine-routing failure code. */ + readonly code: string + + /** + * @param message - human-readable failure description without raw bytes or host paths. + * @param code - stable machine-routing code. + * @param options - optional chained cause. + */ + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, options) + this.name = 'AttachmentError' + this.code = code + } +} diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index ebe6ad59c3..9b3b8dd92b 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -8,7 +8,8 @@ import type { StoredImageAttachment, } from './types.ts' -export { AttachmentError, AttachmentId } from './types.ts' +export { AttachmentId } from './brand.ts' +export { AttachmentError } from './error.ts' export type { AttachmentId as AttachmentIdType, ImageAttachmentLimits, diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index c443cb8763..102209553b 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -1,18 +1,8 @@ /** Durable attachment vocabulary. @module @deepseek-ai/dsh-attachment/types */ -import type { Branded } from '@deepseek-ai/dsh-brand' +import type { AttachmentId } from './brand.ts' -/** Opaque content-addressed identifier for one immutable attachment object. */ -export type AttachmentId = Branded<'AttachmentId'> - -/** - * Brand a validated storage identifier. - * @param value - backend-produced opaque identifier. - * @returns the branded identifier. - */ -export function AttachmentId(value: string): AttachmentId { - return value as AttachmentId -} +export type { AttachmentId } from './brand.ts' /** Raster image formats accepted by the version-one attachment path. */ export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' @@ -56,20 +46,3 @@ export interface StoredImageAttachment { ref: ImageAttachmentRef data: Uint8Array } - -/** Stable failures suitable for host RPC error mapping. */ -export class AttachmentError extends Error { - /** Stable machine-routing failure code. */ - readonly code: string - - /** - * @param message - human-readable failure description without raw bytes or host paths. - * @param code - stable machine-routing code. - * @param options - optional chained cause. - */ - constructor(message: string, code: string, options?: ErrorOptions) { - super(message, options) - this.name = 'AttachmentError' - this.code = code - } -} diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 5b3483dd7a..ccb05f6faa 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 6f4d8bf15fb581d184a1bb36c912a88a319adf26 -README.zh.md: 6ae5291aee8e7e12d78a9c06c2ed9a1432b4f2a0 +README.md: 4a4ce324a0482072164d3c4f6badd464bfacfc6f +README.zh.md: 7fb421ae206563a625df5bd53fbcef1f585bd269 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 6f4d8bf15f..4a4ce324a0 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -22,5 +22,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work +- **`attachments` is a hard inject** — the route plugin (and `host-apiproxy`) will not mount until an attachment backend provides `ctx.attachments`, and a composition missing one stalls silently as a cordis inject gap rather than failing loud; text-only deployments therefore still carry the native `sharp` dependency through `attachment-local`. A capability-degraded (image-refusing) mount is deliberate deferred work. - **history's implicit resume is arguable** — opening history on an unattached session pulls an agent up host-side; the pure-persistence-read alternative is recorded in the rt-core reconciliation ledger, unchanged in P-I. This package's consumers see it as latency on first open. - **`ToolEventView`/`ToolCallView`/`ToolResultView` re-exports are scheduled for removal** — they fall when the toolview migration deletes the host `viewFor` line (presentation belongs to the client); the fixture keeps a local `viewFor` mirror until then. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 6ae5291aee..7fb421ae20 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -22,5 +22,6 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust ## 已知限制与暂缓事项 +- **`attachments` 是硬性注入依赖**:路由插件(以及 `host-apiproxy`)在附件后端提供 `ctx.attachments` 之前不会挂载;缺少后端的组合会以 cordis 注入缺口的形式静默停滞,而非响亮失败;因此纯文本部署也会经由 `attachment-local` 携带原生 `sharp` 依赖。降级为拒绝图片的挂载方式是有意延期的工作。 - **history 的隐式恢复存在争议**:在未附加的会话上打开 history,会在主机侧拉起 agent;纯持久化读取的替代方案记录在 rt-core 协调账本中,P-I 不作改变。该包的消费方会在首次打开时感受到这段延迟。 - **计划移除 `ToolEventView`/`ToolCallView`/`ToolResultView` 的重新导出**:当 toolview 迁移删除主机 `viewFor` 行时,它们会一并移除(呈现属于客户端);在此之前,fixture 保留一份局部 `viewFor` 镜像。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index a59bcfd6c5..f157b31619 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -147,8 +147,10 @@ export function apply(ctx: Context): void { next.setDraft(draft) from.setDraft('') } - if (imageIds.length > 0) { - next.addImages(imageIds) + // Transfer only on acceptance: a destination shell mid-submission + // refuses, and the drafts must stay owned (and releasable) by the + // source shell instead of silently leaking their object URLs. + if (imageIds.length > 0 && next.addImages(imageIds)) { for (const id of imageIds) from.removeImage(id) } } @@ -199,7 +201,12 @@ export function apply(ctx: Context): void { addImages: (files) => { try { const images = conversation.createDraftImages(files) - shell.addImages(images.map(image => image.id)) + if (!shell.addImages(images.map(image => image.id))) { + // Refused intake (machineBusy raced a submission): release the + // just-created previews instead of stranding their object URLs. + conversation.releaseDraftImages(images) + return null + } return null } catch (error: unknown) { return error instanceof Error ? error.message : String(error) diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 18215ef41d..69374cac03 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -32,8 +32,12 @@ export interface InputTarget { export interface SessionInput extends InputTarget { /** Single write path for draft text (all mutation rides machine events). */ setDraft(text: string): void - /** Append ordered browser-owned draft attachment ids. */ - addImages(ids: readonly DraftAttachmentId[]): void + /** + * Append ordered browser-owned draft attachment ids. + * @returns whether the ids were appended; busy admission phases refuse, and + * the caller keeps ownership of refused ids (release or retry them). + */ + addImages(ids: readonly DraftAttachmentId[]): boolean /** Remove one browser-owned draft attachment id. */ removeImage(id: DraftAttachmentId): void /** Drop ids whose browser objects no longer exist. */ @@ -69,8 +73,11 @@ export interface InputService { export interface InputActions { /** Single public draft write path (full next draft; occurrence math via diff scan). */ setDraft(text: string): void - /** Append ordered browser-owned draft attachment ids. */ - addImages(ids: readonly DraftAttachmentId[]): void + /** + * Append ordered browser-owned draft attachment ids. + * @returns whether the ids were appended (busy admission phases refuse). + */ + addImages(ids: readonly DraftAttachmentId[]): boolean /** Remove one browser-owned draft attachment id. */ removeImage(id: DraftAttachmentId): void /** Drop ids whose browser objects no longer exist. */ diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 2d566d7632..0dd7893b71 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -68,7 +68,7 @@ export class SessionInputShell implements SessionInput { /** The public provide-channel action face (one stable identity per session — decision 20). */ readonly actions: InputActions = { setDraft: (text) => { this.setDraft(text) }, - addImages: (ids) => { this.addImages(ids) }, + addImages: ids => this.addImages(ids), removeImage: (id) => { this.removeImage(id) }, pruneImages: (ids) => { this.pruneImages(ids) }, submit: (mode) => { this.submit(mode) }, @@ -101,11 +101,16 @@ export class SessionInputShell implements SessionInput { this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) })) } - /** Append ordered browser-owned draft attachment ids. */ - addImages(ids: readonly DraftAttachmentId[]): void { - if (ids.length === 0 || this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return + /** + * Append ordered browser-owned draft attachment ids. + * @returns whether the ids were appended (busy admission phases refuse). + */ + addImages(ids: readonly DraftAttachmentId[]): boolean { + if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false + if (ids.length === 0) return true this.imageIds = [...this.imageIds, ...ids] this.publish() + return true } /** Remove one browser-owned draft attachment id. */ diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index e220ab7f2e..f21a24b4d7 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -114,7 +114,7 @@ function makeHarness(init?: Partial) { useInput: (() => { throw new Error('unused') }), inputActions: { setDraft: () => {}, - addImages: () => {}, + addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {}, 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 500a687444..0340159851 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -80,7 +80,7 @@ describe('render branch tails', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => {}, + addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {}, @@ -122,7 +122,7 @@ describe('render branch tails', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => {}, + addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {}, diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 4afc2dc81c..acbf35eacc 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -339,6 +339,26 @@ describe('machine pending lock', () => { expect(textarea.readOnly).toBe(true) expect(view.container.querySelector('button[aria-label="Send message"]')!.disabled).toBe(true) }) + + it('addImages reports refusal in busy phases so callers keep draft ownership', () => { + const { shell } = bench() + act(() => { + shell.setDraft('/goal ') + shell.beginCommand( + { + token: '/goal ', + submit: () => new Promise(() => {}), // never settles: stays submitting + }, + { start: 0, end: 6, draftRev: shell.snapshot.draftRev }, + ) + shell.submit('queue') + }) + expect(shell.snapshot.phase).toBe('submitting') + // A refused batch must be observable (the workspace-switch transfer keeps + // the source shell's drafts alive instead of leaking their object URLs). + expect(shell.addImages(['busy-1' as never])).toBe(false) + expect(shell.snapshot.imageIds).toEqual([]) + }) }) describe('decorations', () => { diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index 494ae0b498..0a5616bf48 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -395,7 +395,7 @@ describe('DetailsPanel Output section', () => { useSessions={bindSnapshotSelector(sessions)} useWorkspaces={bindSnapshotSelector(workspaces)} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, addImages: () => {}, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} + inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} @@ -566,7 +566,7 @@ describe('DetailsPanel Output section', () => { baselinesReady: true, recentWorkspaceId: undefined, }))} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, addImages: () => {}, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} + inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index 1607369fea..d6d3bc8e8b 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -5,7 +5,7 @@ */ import type { Context } from 'cordis' -import { createUserMessage, BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm' +import { contentHasImage, createUserMessage, BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, GenerateOptions, Message, TokenUsage, ToolSchema, } from '@deepseek-ai/dsh-llm' @@ -205,14 +205,8 @@ function finishError(finish: FinishReason): Error | undefined { function summaryText( blocks: readonly ContentBlock[], ): Array> { - if (containsImage(blocks)) { + if (contentHasImage(blocks)) { throw new LlmError('compaction summary cannot contain image output', 'UNSUPPORTED_CONTENT') } return blocks.filter((block): block is Extract => block.type === 'text') } - -/** Detect images recursively so no structured result can hide a silent visual drop. */ -function containsImage(blocks: readonly ContentBlock[]): boolean { - return blocks.some(block => block.type === 'image' - || (block.type === 'tool-result' && containsImage(block.content))) -} diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 5609e6db85..7e7720fbeb 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: 3badccd4144babc474f52fa44598ddec3c253e65 -README.zh.md: 8ccd5bf95c70c79e968b3ea8d0f6a48c62359ae3 +README.md: 7ee67009c33d9df843e1d5aa71e727c0c798d4a0 +README.zh.md: f7d1503d2947f84dbc21cb74e10678c4e14cb248 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 3badccd414..7ee67009c3 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -40,6 +40,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work +- **`attachments` is a hard inject** — the proxy will not mount until an attachment backend provides `ctx.attachments`; a composition missing one stalls silently as a cordis inject gap rather than failing loud (same gap as the connection route; a capability-degraded mount is deferred work). - **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals). - **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 8ccd5bf95c..f7d1503d29 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -40,6 +40,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 已知限制与延期工作 +- **`attachments` 是硬性注入依赖**:代理在附件后端提供 `ctx.attachments` 之前不会挂载;缺少后端的组合会以 cordis 注入缺口的形式静默停滞,而非响亮失败(与 connection 路由是同一缺口;降级挂载属于延期工作)。 - **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。 - **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 617ad9ad5b..4e2ec7bec0 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -13,7 +13,7 @@ import type { } from '@deepseek-ai/dsh-agent' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { contentHasImage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' @@ -65,11 +65,11 @@ const DEFAULT_MAX_MESSAGES = 50 const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message']) function decodeBase64(data: string): Uint8Array { - if (data.length === 0 || data.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)) { - throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') - } const decoded = Buffer.from(data, 'base64') - if (decoded.toString('base64') !== data) { + // One canonical-form check: any non-canonical input (whitespace, url-safe + // alphabet, bad padding, truncated groups) fails the exact round-trip, so a + // pre-filter regex over the multi-MiB upload string would be pure overhead. + if (data.length === 0 || decoded.toString('base64') !== data) { throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') } return new Uint8Array(decoded) @@ -147,12 +147,6 @@ function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => b return undefined } -/** True when typed model content contains an image, including nested tool results. */ -function contentHasImage(content: readonly ContentBlock[]): boolean { - return content.some(block => block.type === 'image' - || (block.type === 'tool-result' && contentHasImage(block.content))) -} - /** True when the current model-visible surface contains an image. */ function messagesHaveImage(messages: readonly { content: readonly ContentBlock[] }[]): boolean { return messages.some(message => contentHasImage(message.content)) @@ -627,11 +621,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro */ const queuedMirror = new Map() /** - * Claimed-but-unpublished queued occurrences: dequeue is not publication — - * the `user/message` append follows it asynchronously — so an image carrier - * stays a model-selection gate until its durable event lands, its discard - * arrives, or the admission's turn settles idle. Kept apart from the mirror - * so the mux-open snapshot never replays a claimed occurrence as queued. + * Unpublished occurrences that must still gate model selection: a queued + * item from dequeue (claim is not publication — the `user/message` append + * follows asynchronously) and a steering item from enqueue (it never enters + * the queued mirror, and its `steering/message` append is a separate outbox + * hop). Entries retire on their durable event, discard, or idle. Kept apart + * from the mirror so the mux-open snapshot never replays them as queued. */ const pendingPublication = new Map() type UnseenQueueEvent = @@ -692,7 +687,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } const disposers = [ ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => { - if (item.placement !== 'queued') return + if (item.placement === 'steering') { + // A steering carrier never enters the queued mirror, yet its image + // must gate model selection from enqueue until its steering/message + // event publishes (or the admission ends): the outbox hop between + // steer() and the append is asynchronous, and a text-only switch + // accepted inside it would strand every later turn. + const pending = pendingPublication.get(agent.id) ?? [] + pending.push(item) + pendingPublication.set(agent.id, pending) + return + } const unseen = takeUnseen(agent.id, item.id) if (unseen?.kind === 'terminal') return let entries = queuedMirror.get(agent.id) @@ -726,10 +731,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (retire(agent, item)) publishQueue(agent.id) }), ctx.on('session/event', (session: Session, event: SessionEvent) => { - if (event.type !== 'user/message') return + const id = event.type === 'user/message' + ? event.data.id + : event.type === 'steering/message' + ? (event.data as { message: UserMessage }).message.id + : undefined + if (id === undefined) return const pending = pendingPublication.get(session.id) if (pending === undefined) return - const index = pending.findIndex(entry => entry.message.id === (event.data).id) + const placement = event.type === 'user/message' ? 'queued' : 'steering' + const index = pending.findIndex(entry => entry.message.id === id && entry.placement === placement) if (index === -1) return pending.splice(index, 1) if (pending.length === 0) pendingPublication.delete(session.id) @@ -1385,6 +1396,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro updateQueue(request) { const { sessionId, itemId, action } = request.payload + // Queue edits bypass durablePromptContent (no admission, no durable + // reference, no model-capability recheck), so only text blocks may be + // written through this boundary; image intake is prompt-only. + if (action.kind === 'edit' && action.content.some(block => block.type !== 'text')) { + return Promise.resolve(err(request, { + code: 'attachment-error', + message: 'queue edits accept text content only', + details: { reason: 'QUEUE_EDIT_NON_TEXT' }, + })) + } const agent = ctx.agents.get(sessionId) if (agent === undefined || agent.updateInbox(itemId, action) === 'not-found') { return Promise.resolve(err(request, { diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 6e4302b770..4cf5591d4d 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -387,6 +387,70 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) + it('gates selection on a steering image from enqueue until its event publishes', async () => { + const { ctx, sessionId, agent } = await harness() + registerTextOnly(ctx) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const steering = { + id: 's-1', role: 'user', source: { kind: 'user' }, + content: [{ type: 'image', attachment: { attachmentId: 'att-s', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }], + } as never + const steeringItem = { id: 'i-s-1', message: steering, placement: 'steering' } as never + // A steering carrier never enters the queued mirror, yet the outbox hop + // between steer() and its append must not open a text-only switch window. + ctx.emit('agent/inbox/enqueue', agent, steeringItem) + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + + ctx.emit('agent/inbox/dequeue', agent, steeringItem) + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + + // Publication hands the gate over to the durable surface. + agent.session.append('steering/message', { turn: 1, message: steering }, { surfaceOp: 'append' }) + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + await ctx.fiber.dispose() + }) + + it('re-opens selection when an admission ends idle without publication', async () => { + const { ctx, sessionId, agent } = await harness() + registerTextOnly(ctx) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const rejected = { + id: 'r-1', role: 'user', source: { kind: 'user' }, + content: [{ type: 'image', attachment: { attachmentId: 'att-r', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }], + } as never + const rejectedItem = { id: 'i-r-1', message: rejected, placement: 'queued' } as never + ctx.emit('agent/inbox/enqueue', agent, rejectedItem) + ctx.emit('agent/inbox/dequeue', agent, rejectedItem) + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + + // Idle proves the admission ended without publication; nothing durable + // requires an image route, so the text-only switch must be accepted again. + ctx.emit('agent/status', agent, 'idle') + expect(expectValue(await api.sessions.selectModel(request({ + sessionId, provider: 'text-only', model: 'plain', + }))).selected).toEqual({ provider: 'text-only', model: 'plain' }) + await ctx.fiber.dispose() + }) + + it('rejects a queue edit that injects unadmitted image content', async () => { + const { ctx, sessionId, agent } = await harness() + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + Object.assign(agent, { updateInbox: () => 'applied' }) + const denied = await api.sessions.updateQueue(request({ + sessionId, + itemId: 'i-x' as never, + action: { + kind: 'edit' as const, + content: [{ type: 'image', attachment: { attachmentId: 'att-x', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }] as never, + }, + })) + expect(denied.result).toMatchObject({ + ok: false, + error: { code: 'attachment-error', details: { reason: 'QUEUE_EDIT_NON_TEXT' } }, + }) + await ctx.fiber.dispose() + }) + it('serializes an image save with a concurrent model selection', async () => { const { ctx, sessionId, agent } = await harness() registerTextOnly(ctx) diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index d7db2749c1..c05214ced8 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -7,7 +7,7 @@ * @module dsh-llm-deepseek/serialize */ -import { LlmError } from '@deepseek-ai/dsh-llm' +import { contentHasImage, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { WireMessage, WireRequest, WireTool } from './types.ts' @@ -62,11 +62,8 @@ function flattenText(blocks: ContentBlock[]): string { /** Reject core image content before any text-flattening path can silently erase it. */ function assertTextOnly(blocks: readonly ContentBlock[]): void { - for (const block of blocks) { - if (block.type === 'image') { - throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT') - } - if (block.type === 'tool-result') assertTextOnly(block.content) + if (contentHasImage(blocks)) { + throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT') } } diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 3b5bb9b9cb..0239273c63 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -33,7 +33,8 @@ import type { import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import { resolveProfiles } from './config.ts' import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' -import { contentHasImage, toPiContext } from './context.ts' +import { contentHasImage } from '@deepseek-ai/dsh-llm' +import { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' /** Constructor options for {@link PiAiAdapter}. */ @@ -189,11 +190,7 @@ export class PiAiAdapter extends LlmAdapter { using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT') try { - const containsImage = options.messages.some((message) => { - // The discriminant is part of same-process message validity and is read before content. - void message.role - return contentHasImage(message.content) - }) + const containsImage = options.messages.some(message => contentHasImage(message.content)) if (containsImage && !model.input.includes('image')) { throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT') } diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index b1464d7289..678820510e 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -4,7 +4,7 @@ * @module dsh-llm-pi-ai/context */ -import { CallId, LlmError } from '@deepseek-ai/dsh-llm' +import { CallId, contentHasImage, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai' @@ -18,15 +18,6 @@ function flattenText(message: Message): string { .join('') } -/** - * Return whether content contains an image, including nested tool results. - * @param blocks - content to inspect recursively. - * @returns whether any nested block is an image. - */ -export function contentHasImage(blocks: readonly ContentBlock[]): boolean { - return blocks.some(block => block.type === 'image' - || (block.type === 'tool-result' && contentHasImage(block.content))) -} /** Flatten text recursively inside one tool result. */ function toolResultText(blocks: readonly ContentBlock[]): string { diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index cde449d34a..32888476f6 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -638,7 +638,7 @@ describe('provider profile lifecycle', () => { describe('abort wiring', () => { it('preserves an unknown pre-dispatch adapter Error exactly', async () => { const original = new Error('SDK context conversion exploded') - const message = Object.defineProperty({}, 'role', { + const message = Object.defineProperty({}, 'content', { get() { throw original }, }) const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) @@ -656,7 +656,7 @@ describe('abort wiring', () => { it('lets a concurrent caller abort classify a pre-dispatch adapter failure', async () => { const controller = new AbortController() const original = new Error('conversion lost its caller') - const message = Object.defineProperty({}, 'role', { + const message = Object.defineProperty({}, 'content', { get() { controller.abort('caller cancelled during conversion') throw original diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts new file mode 100644 index 0000000000..19b760a02a --- /dev/null +++ b/packages/llm/llm/src/content.ts @@ -0,0 +1,16 @@ +/** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */ + +import type { ContentBlock } from './types.ts' + +/** + * True when typed model content contains an image block, walking nested + * tool-result content. This is the one recursive image walk shared by every + * image policy (capability gating, text-only serialization, compaction + * survey), so a consumer cannot silently diverge on nesting depth. + * @param content - typed model content blocks. + * @returns whether any nested block is an image. + */ +export function contentHasImage(content: readonly ContentBlock[]): boolean { + return content.some(block => block.type === 'image' + || (block.type === 'tool-result' && contentHasImage(block.content))) +} diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 95c7214ee5..b95d966177 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -31,6 +31,7 @@ export * from './brand.ts' export * from './never.ts' export * from './error.ts' export * from './types.ts' +export * from './content.ts' export * from './message.ts' export * from './retry-policy.ts' export { BlockAssembler } from './assembler.ts' From 4a7d4b812d2ac0e827b6081701e909fe70a25594 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 15:04:34 +0800 Subject: [PATCH 022/229] test: cover probeImage error wrapping and the retreating durable boundary --- .../attachment/attachment-local/tests/image.spec.ts | 11 ++++++++++- .../attachment/attachment-local/tests/store.spec.ts | 8 ++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/attachment/attachment-local/tests/image.spec.ts b/packages/attachment/attachment-local/tests/image.spec.ts index ccfe7f62e7..72a258e2bd 100644 --- a/packages/attachment/attachment-local/tests/image.spec.ts +++ b/packages/attachment/attachment-local/tests/image.spec.ts @@ -1,6 +1,6 @@ import sharp from 'sharp' import { describe, expect, it } from 'vitest' -import { detectImage } from '../src/image.ts' +import { detectImage, probeImage } from '../src/image.ts' async function raster(format: 'png' | 'jpeg' | 'webp' | 'gif'): Promise { const image = sharp({ @@ -39,4 +39,13 @@ describe('raster decoding', () => { await expect(sharp(truncated).metadata()).resolves.toMatchObject({ width: 3, height: 2 }) await expect(detectImage(truncated)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) }) + + it('probes malformed bytes and unsupported formats into the same stable error', async () => { + await expect(probeImage(Uint8Array.of(1, 2, 3))) + .rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + const unsupported = await sharp({ + create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } }, + }).tiff().toBuffer() + await expect(probeImage(unsupported)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + }) }) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 5838b8748c..159c4e20b8 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -77,6 +77,14 @@ describe('local attachment store', () => { ]) }) + it('retreats the durable boundary to the closest existing ancestor when the home directory does not exist yet', async () => { + const storageRoot = join(await root(), 'home', 'attachments', 'v1') + + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + + await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) + }) + it('publishes one private content-addressed object and deduplicates equal bytes', async () => { const storageRoot = await root() const first = await saveImageFile(storageRoot, { From b96d670ecc751a0f74134b81d190e8b3664852f1 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:25:11 -0700 Subject: [PATCH 023/229] fix(workspace-context): commit only represented changes --- .../context/workspace-context/src/files.ts | 6 +-- .../context/workspace-context/src/render.ts | 51 ++++++++++++++----- .../tests/workspace-context.spec.ts | 39 +++++++++++++- 3 files changed, 76 insertions(+), 20 deletions(-) diff --git a/packages/context/workspace-context/src/files.ts b/packages/context/workspace-context/src/files.ts index 3e6a3d5de8..a70fdc5485 100644 --- a/packages/context/workspace-context/src/files.ts +++ b/packages/context/workspace-context/src/files.ts @@ -12,7 +12,7 @@ import { assertNever } from '@deepseek-ai/dsh-llm' import { dshHomeDisplay } from '@deepseek-ai/dsh-paths' import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts' import { trimmedInstructionDigest } from './digest.ts' -import { decodeScopeKey, renderWorkspaceContext, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE, type RenderedWorkspaceContext } from './render.ts' +import { decodeScopeKey, renderWorkspaceInstructionSet, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE, type RenderedWorkspaceContext } from './render.ts' /** An instruction candidate identified by absolute and model-facing paths. */ export interface InstructionFile { @@ -413,9 +413,7 @@ export async function loadBaselineInstructionSet( } const deduped = dedupInstructionFilesByDirectory(loaded) if (deduped.length === 0) return undefined - const rendered = renderWorkspaceContext(deduped, { maxBytes: config.maxBytes }) - const omitted = new Set(rendered.omitted.map(file => file.absolutePath)) - return { rendered, included: deduped.filter(file => !omitted.has(file.absolutePath)) } + return renderWorkspaceInstructionSet(deduped, { maxBytes: config.maxBytes }) } /** diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index 9ab311e942..05dba00736 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -28,6 +28,11 @@ export interface RenderedWorkspaceContext { truncated: TruncatedInstruction[] } +interface RenderedInstructionContext extends RenderedWorkspaceContext { + /** Original files whose file-specific semantic section survived rendering. */ + included: LoadedInstructionFile[] +} + /** Structured dynamic state persisted outside model-visible prompt prose. */ export interface WorkspaceInstructionChange { action: 'set' | 'replace' | 'remove' @@ -174,13 +179,10 @@ export function renderInstructionChanges( }, } const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style) - const omitted = new Set(rendered.omitted.map(file => file.absolutePath)) + const included = new Set(rendered.included.map(file => file.absolutePath)) return { text: rendered.text, - // TODO(rendered-change-proof): retain a transition only when its semantic - // notice survived rendering; a tiny compact budget can currently return - // unrelated notice text while still committing the full state transition. - changes: items.filter(item => !omitted.has(item.file.absolutePath)).map(item => item.change), + changes: items.filter(item => included.has(item.file.absolutePath)).map(item => item.change), } } @@ -248,22 +250,26 @@ function renderInstructionContext( files: LoadedInstructionFile[], maxBytes: number, style: RenderStyle, -): RenderedWorkspaceContext { - if (maxBytes <= 0 || !Number.isFinite(maxBytes)) return { text: '', omitted: files, truncated: [] } +): RenderedInstructionContext { + if (maxBytes <= 0 || !Number.isFinite(maxBytes)) { + return { text: '', omitted: files, truncated: [], included: [] } + } const fullText = buildInstructionText(files, maxBytes, [], [], style) - if (byteLength(fullText) <= maxBytes) return { text: fullText, omitted: [], truncated: [] } + if (byteLength(fullText) <= maxBytes) { + return { text: fullText, omitted: [], truncated: [], included: files } + } for (let start = 1; start < files.length; start += 1) { const included = files.slice(start) const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath })) const suffixText = buildInstructionText(included, maxBytes, omitted, [], style) - if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [] } + if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [], included } } const mostSpecific = files.at(-1) /* v8 ignore next -- callers only reach this after a non-empty fullText was built. */ - if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [] } + if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [], included: [] } const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath })) for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) { @@ -274,7 +280,7 @@ function renderInstructionContext( includedBytes: byteLength(truncatedFile.content), }] const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle) - if (byteLength(text) <= maxBytes) return { text, omitted, truncated } + if (byteLength(text) <= maxBytes) return { text, omitted, truncated, included: [mostSpecific] } } const truncated = [{ @@ -286,9 +292,26 @@ function renderInstructionContext( const compactWithHeading = escapeInstructionFrameBody( [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'), ) - if (byteLength(compactWithHeading) <= maxBytes) return { text: compactWithHeading, omitted, truncated } + if (byteLength(compactWithHeading) <= maxBytes) { + return { text: compactWithHeading, omitted, truncated, included: [mostSpecific] } + } const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes) - return { text, omitted, truncated } + return { text, omitted, truncated, included: [] } +} + +/** + * Render a baseline together with the exact source files semantically represented in it. + * @param files - loaded files ordered from broadest to most specific. + * @param options - required rendering byte budget. + * @returns bounded public rendering plus the original files whose semantic sections survived. + * @internal + */ +export function renderWorkspaceInstructionSet( + files: LoadedInstructionFile[], + options: { maxBytes: number }, +): { rendered: RenderedWorkspaceContext; included: LoadedInstructionFile[] } { + const { included, ...rendered } = renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE) + return { rendered, included } } /** @@ -301,5 +324,5 @@ export function renderWorkspaceContext( files: LoadedInstructionFile[], options: { maxBytes: number }, ): RenderedWorkspaceContext { - return renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE) + return renderWorkspaceInstructionSet(files, options).rendered } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index edbb4b3145..c6fcb80650 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -828,6 +828,38 @@ describe('workspace context rendering', () => { expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(20) }) + it('does not commit a change when only the generic compact notice survives', () => { + const change = { + action: 'set' as const, + scope: sk('pkg', 'AGENTS.md'), + path: 'pkg/AGENTS.md', + digest: 'digest', + } + const rendered = renderInstructionChanges([{ + change, + file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) }, + }], 20) + + expect(rendered.text).toBe('Workspace instructio') + expect(rendered.changes).toEqual([]) + }) + + it('commits a change when its file-specific semantic section survives truncation', () => { + const change = { + action: 'replace' as const, + scope: sk('pkg', 'AGENTS.md'), + path: 'pkg/AGENTS.md', + digest: 'digest', + } + const rendered = renderInstructionChanges([{ + change, + file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) }, + }], 400) + + expect(rendered.text).toContain('Updated instructions from: pkg/AGENTS.md') + expect(rendered.changes).toEqual([change]) + }) + it('keeps compact truncation notices within budget when a multibyte display path is cut', () => { const rendered = renderWorkspaceContext([ { absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) }, @@ -1298,9 +1330,12 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - expect(agent.session.events.filter(event => + const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user', - )).toHaveLength(1) + ) + expect(contexts).toHaveLength(1) + const source = contexts[0]?.type === 'user/message' ? contexts[0].data.source : undefined + expect(source?.kind === 'workspace-instructions' ? source.changes : undefined).toEqual([]) expect(derivedText(agent)).not.toContain('workspace-context:') } finally { await rm(root, { recursive: true, force: true }) From 36b8efd2c6afc5d42e00b16559e242767b6caead Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:07:38 -0700 Subject: [PATCH 024/229] fix(workspace-context): require rendered instruction content --- .../context/workspace-context/src/files.ts | 15 +++-- .../context/workspace-context/src/render.ts | 47 ++++++++++---- .../tests/workspace-context.spec.ts | 64 ++++++++++++++++++- 3 files changed, 103 insertions(+), 23 deletions(-) diff --git a/packages/context/workspace-context/src/files.ts b/packages/context/workspace-context/src/files.ts index a70fdc5485..ef6d61f327 100644 --- a/packages/context/workspace-context/src/files.ts +++ b/packages/context/workspace-context/src/files.ts @@ -12,7 +12,14 @@ import { assertNever } from '@deepseek-ai/dsh-llm' import { dshHomeDisplay } from '@deepseek-ai/dsh-paths' import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts' import { trimmedInstructionDigest } from './digest.ts' -import { decodeScopeKey, renderWorkspaceInstructionSet, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE, type RenderedWorkspaceContext } from './render.ts' +import { + decodeScopeKey, + renderWorkspaceInstructionSet, + USER_GLOBAL_DIRECTORY, + USER_GLOBAL_FILE, + type RenderedInstructionSet, + type RenderedWorkspaceContext, +} from './render.ts' /** An instruction candidate identified by absolute and model-facing paths. */ export interface InstructionFile { @@ -54,12 +61,6 @@ interface LoadOptions extends DiscoverOptions { maxSourceBytes?: number } -/** Rendered baseline plus the files that survived byte budgeting. */ -export interface RenderedInstructionSet { - rendered: RenderedWorkspaceContext - included: LoadedInstructionFile[] -} - /** Tri-state scope probe that distinguishes confirmed absence from provider failure. */ export type ScopeInstructionProbe = | { kind: 'present'; file: ProbedInstructionFile } diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index 05dba00736..62cf5cbbdf 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -29,7 +29,17 @@ export interface RenderedWorkspaceContext { } interface RenderedInstructionContext extends RenderedWorkspaceContext { - /** Original files whose file-specific semantic section survived rendering. */ + /** + * Original files whose file-specific section text survived rendering. This + * is not the complement of `omitted`: a truncated file may be represented + * here and in `truncated`, while a notice-only file appears in neither. + */ + represented: LoadedInstructionFile[] +} + +/** Rendered baseline plus the files whose current content survived budgeting. */ +export interface RenderedInstructionSet { + rendered: RenderedWorkspaceContext included: LoadedInstructionFile[] } @@ -56,6 +66,12 @@ function byteLength(value: string): number { return Buffer.byteLength(value, 'utf8') } +function zeroContentTruncatedPaths(truncated: TruncatedInstruction[]): Set { + return new Set(truncated + .filter(item => item.originalBytes > 0 && item.includedBytes === 0) + .map(item => item.displayPath)) +} + function truncateUtf8(value: string, maxBytes: number): string { let truncated = Buffer.from(value, 'utf8').subarray(0, Math.max(0, maxBytes)).toString('utf8') while (byteLength(truncated) > maxBytes) { @@ -179,10 +195,14 @@ export function renderInstructionChanges( }, } const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style) - const included = new Set(rendered.included.map(file => file.absolutePath)) + const represented = new Set(rendered.represented.map(file => file.absolutePath)) + const contentOmitted = zeroContentTruncatedPaths(rendered.truncated) return { text: rendered.text, - changes: items.filter(item => included.has(item.file.absolutePath)).map(item => item.change), + changes: items + .filter(item => represented.has(item.file.absolutePath) + && (item.change.action === 'remove' || !contentOmitted.has(item.file.displayPath))) + .map(item => item.change), } } @@ -252,24 +272,24 @@ function renderInstructionContext( style: RenderStyle, ): RenderedInstructionContext { if (maxBytes <= 0 || !Number.isFinite(maxBytes)) { - return { text: '', omitted: files, truncated: [], included: [] } + return { text: '', omitted: files, truncated: [], represented: [] } } const fullText = buildInstructionText(files, maxBytes, [], [], style) if (byteLength(fullText) <= maxBytes) { - return { text: fullText, omitted: [], truncated: [], included: files } + return { text: fullText, omitted: [], truncated: [], represented: files } } for (let start = 1; start < files.length; start += 1) { const included = files.slice(start) const omitted = files.slice(0, start).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath })) const suffixText = buildInstructionText(included, maxBytes, omitted, [], style) - if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [], included } + if (byteLength(suffixText) <= maxBytes) return { text: suffixText, omitted, truncated: [], represented: included } } const mostSpecific = files.at(-1) /* v8 ignore next -- callers only reach this after a non-empty fullText was built. */ - if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [], included: [] } + if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [], represented: [] } const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath })) for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) { @@ -280,7 +300,7 @@ function renderInstructionContext( includedBytes: byteLength(truncatedFile.content), }] const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle) - if (byteLength(text) <= maxBytes) return { text, omitted, truncated, included: [mostSpecific] } + if (byteLength(text) <= maxBytes) return { text, omitted, truncated, represented: [mostSpecific] } } const truncated = [{ @@ -293,10 +313,10 @@ function renderInstructionContext( [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'), ) if (byteLength(compactWithHeading) <= maxBytes) { - return { text: compactWithHeading, omitted, truncated, included: [mostSpecific] } + return { text: compactWithHeading, omitted, truncated, represented: [mostSpecific] } } const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes) - return { text, omitted, truncated, included: [] } + return { text, omitted, truncated, represented: [] } } /** @@ -309,9 +329,10 @@ function renderInstructionContext( export function renderWorkspaceInstructionSet( files: LoadedInstructionFile[], options: { maxBytes: number }, -): { rendered: RenderedWorkspaceContext; included: LoadedInstructionFile[] } { - const { included, ...rendered } = renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE) - return { rendered, included } +): RenderedInstructionSet { + const { represented, ...rendered } = renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE) + const contentOmitted = zeroContentTruncatedPaths(rendered.truncated) + return { rendered, included: represented.filter(file => !contentOmitted.has(file.displayPath)) } } /** diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index c6fcb80650..0331fcea8e 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -860,6 +860,26 @@ describe('workspace context rendering', () => { expect(rendered.changes).toEqual([change]) }) + it.each([ + { action: 'set' as const, maxBytes: 327, heading: 'Additional instructions from:' }, + { action: 'replace' as const, maxBytes: 256, heading: 'Updated instructions from:' }, + ])('does not commit a $action change when its heading survives with zero content bytes', ({ action, maxBytes, heading }) => { + const change = { + action, + scope: sk('pkg', 'AGENTS.md'), + path: 'pkg/AGENTS.md', + digest: 'digest', + } + const rendered = renderInstructionChanges([{ + change, + file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) }, + }], maxBytes) + + expect(rendered.text).toContain(heading) + expect(rendered.text).toContain('from 1000 to 0 bytes') + expect(rendered.changes).toEqual([]) + }) + it('keeps compact truncation notices within budget when a multibyte display path is cut', () => { const rendered = renderWorkspaceContext([ { absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) }, @@ -1318,14 +1338,14 @@ describe('workspace context request injection', () => { } }) - it('does not expose state markers when a tiny budget reduces the baseline contribution', async () => { + it('does not expose state markers when a baseline heading survives with zero content bytes', async () => { const root = await tempRepo() const home = await tempRepo() try { await mkdir(join(root, '.git'), { recursive: true }) - await write(join(root, 'AGENTS.md'), 'repo rule') + await write(join(root, 'AGENTS.md'), 'x'.repeat(1000)) const ctx = new Context() - await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 10 }) + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 120 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -1336,6 +1356,8 @@ describe('workspace context request injection', () => { expect(contexts).toHaveLength(1) const source = contexts[0]?.type === 'user/message' ? contexts[0].data.source : undefined expect(source?.kind === 'workspace-instructions' ? source.changes : undefined).toEqual([]) + expect(derivedText(agent)).toContain('Instructions from: AGENTS.md') + expect(derivedText(agent)).toContain('from 1000 to 0 bytes') expect(derivedText(agent)).not.toContain('workspace-context:') } finally { await rm(root, { recursive: true, force: true }) @@ -3385,6 +3407,42 @@ describe('dynamic nested workspace context injection', () => { } }) + it('retries a nested instruction touch when only a truncated budget notice was rendered', async () => { + const root = join(await tempRepo(), 'virtual-repo') + const home = join(await tempRepo(), 'virtual-home') + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + const instructionPath = join(root, 'pkg/AGENTS.md') + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(instructionPath, { type: 'file', content: 'x'.repeat(1000) }) + fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) + await ctx.plugin(ToolFs) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 20 }) + const agent = stubAgent(root) + + const first = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-tiny-budget-1'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, + }) + const second = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-tiny-budget-2'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, + }) + + expect(first.additionalContexts).toBeUndefined() + expect(second.additionalContexts).toBeUndefined() + expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(2) + } finally { + await ctx.fiber.dispose() + await rm(dirname(root), { recursive: true, force: true }) + await rm(dirname(home), { recursive: true, force: true }) + } + }) + it('does not attach nested instructions after a failed file read', async () => { const root = await tempRepo() const home = await tempRepo() From 44d7dc7a737676f5f2539da93fb88824bb79454e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 17:17:25 +0800 Subject: [PATCH 025/229] fix(web): keep image intake inert without a session --- .../client/ui-conversation/src/client/skeleton/InputBar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 9b6323d40d..5e6dde5cbe 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -293,7 +293,7 @@ export function InputBar({ const onDragOver = (event: DragEvent): void => { if (!event.dataTransfer.types.includes('Files')) return event.preventDefault() - event.dataTransfer.dropEffect = locked || machineBusy ? 'none' : 'copy' + event.dataTransfer.dropEffect = locked || machineBusy || addImages === undefined ? 'none' : 'copy' } const onDragLeave = (event: DragEvent): void => { @@ -307,7 +307,7 @@ export function InputBar({ event.preventDefault() dragDepthRef.current = 0 setDragActive(false) - if (locked || machineBusy) return + if (locked || machineBusy || addImages === undefined) return const dropped = [...event.dataTransfer.files] if (dropped.length === 0) return setDropError(addImages(dropped)) From 10c13391774d48f9b219678a377aba7984505f0b Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:33:33 -0700 Subject: [PATCH 026/229] docs(workspace-context): define partial render commits --- .../feature/2026-06-24-workspace-context.i18n.yaml | 4 ++-- .../feature/2026-06-24-workspace-context.md | 2 +- .../feature/2026-06-24-workspace-context.zh.md | 2 +- packages/context/workspace-context/README.i18n.yaml | 4 ++-- packages/context/workspace-context/README.md | 2 +- packages/context/workspace-context/README.zh.md | 2 +- packages/context/workspace-context/src/render.ts | 9 ++++++--- .../tests/workspace-context.spec.ts | 13 +++++++++---- 8 files changed, 23 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 073aa9fa4b..71a11a4666 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-24-workspace-context.md -2026-06-24-workspace-context.md: 8baced0143abb38ff34d16a072761ec016a53d6e -2026-06-24-workspace-context.zh.md: 392d57f344b97c1816f691fef75440f815bccb50 +2026-06-24-workspace-context.md: 004792bbbcf6a99e2c5cdcd4c4d640d9c7d39877 +2026-06-24-workspace-context.zh.md: a7d0ea1884c8cb69c96e5192c0fbc1d2ae5ea4dd diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 8baced0143..004792bbbc 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -52,7 +52,7 @@ Every workspace context event stores versioned metadata with `{ action, scope, p At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `user/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy. -An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch. +An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. A change enters metadata or pending state only when its file-specific section retains at least one content byte, or when the original content is genuinely empty. Partial truncation commits the full-content digest once any byte survives; zero-content truncation remains eligible on a later touch. The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. A later successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends baseline edits or removals as dynamic messages; it never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the first resumed request. diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index 392d57f344..a7d0ea1884 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -52,7 +52,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, 协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `user/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。 -路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 +路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入元数据或待处理状态。只要任一字节保留下来,部分截断就会提交完整内容 digest;零内容截断仍可在后续触碰中处理。 只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。后续成功的文件系统触碰会在压缩后重新添加未变化的基线 scope,或把基线编辑或移除操作追加为动态消息;它绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。恢复时准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。 diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 102c991391..85980bfcf0 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/workspace-context/README.md -README.md: 2669422ec1fa7a74ba329cd96ee6b7e5e6da7e9d -README.zh.md: e9fab4c6998f1193068389b41bdd7fa7d8c98dca +README.md: dc8889cf99b1af691c72af621e78dfb93228ad92 +README.zh.md: ef1dd31ecd5b368aa3e92176edda5aa53268f7d8 diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 2669422ec1..dc8889cf99 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -50,7 +50,7 @@ The plugin owns the complete `` framing, and every injected `us Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; the complete startup or resume baseline also carries `baseline: true`. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. -An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. +An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. A model-visible change enters the source, pending state, and version cache only when its file-specific section retains at least one content byte, or when its original content is genuinely empty. Partial truncation records the complete-content digest once any content byte survives; truncation to zero remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface; the next successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it injects a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes before its first request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop prepares its baseline. diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index e9fab4c699..ef1dd31ecd 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -50,7 +50,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when 模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整的启动或恢复基线还会携带 `baseline: true`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 -路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 +路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。模型可见变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入来源、pending 状态和版本 cache。只要任一内容字节保留下来,部分截断就会记录完整内容的 digest;截断到零字节则仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态;下一次成功的文件系统 touch 会在压缩后重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复 loop 准备基线时可见。 diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index 62cf5cbbdf..bd59decdb4 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -37,7 +37,10 @@ interface RenderedInstructionContext extends RenderedWorkspaceContext { represented: LoadedInstructionFile[] } -/** Rendered baseline plus the files whose current content survived budgeting. */ +/** + * Rendered baseline plus files whose section retained content, or whose original content was empty. + * A partially rendered file keeps the digest of its complete original content. + */ export interface RenderedInstructionSet { rendered: RenderedWorkspaceContext included: LoadedInstructionFile[] @@ -201,7 +204,7 @@ export function renderInstructionChanges( text: rendered.text, changes: items .filter(item => represented.has(item.file.absolutePath) - && (item.change.action === 'remove' || !contentOmitted.has(item.file.displayPath))) + && !contentOmitted.has(item.file.displayPath)) .map(item => item.change), } } @@ -323,7 +326,7 @@ function renderInstructionContext( * Render a baseline together with the exact source files semantically represented in it. * @param files - loaded files ordered from broadest to most specific. * @param options - required rendering byte budget. - * @returns bounded public rendering plus the original files whose semantic sections survived. + * @returns bounded public rendering plus files with surviving content, including genuinely empty files. * @internal */ export function renderWorkspaceInstructionSet( diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 0331fcea8e..f8a375aa87 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -860,6 +860,7 @@ describe('workspace context rendering', () => { expect(rendered.changes).toEqual([change]) }) + // Each prose-derived budget is the smallest current value that retains the named heading plus a zero-byte marker. it.each([ { action: 'set' as const, maxBytes: 327, heading: 'Additional instructions from:' }, { action: 'replace' as const, maxBytes: 256, heading: 'Updated instructions from:' }, @@ -1338,14 +1339,14 @@ describe('workspace context request injection', () => { } }) - it('does not expose state markers when a baseline heading survives with zero content bytes', async () => { + it.each([10, 120])('does not expose state markers when baseline content is omitted at %i bytes', async (maxBytes) => { const root = await tempRepo() const home = await tempRepo() try { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'AGENTS.md'), 'x'.repeat(1000)) const ctx = new Context() - await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 120 }) + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -1356,8 +1357,12 @@ describe('workspace context request injection', () => { expect(contexts).toHaveLength(1) const source = contexts[0]?.type === 'user/message' ? contexts[0].data.source : undefined expect(source?.kind === 'workspace-instructions' ? source.changes : undefined).toEqual([]) - expect(derivedText(agent)).toContain('Instructions from: AGENTS.md') - expect(derivedText(agent)).toContain('from 1000 to 0 bytes') + if (maxBytes === 120) { + expect(derivedText(agent)).toContain('Instructions from: AGENTS.md') + expect(derivedText(agent)).toContain('from 1000 to 0 bytes') + } else { + expect(derivedText(agent)).not.toContain('Instructions from: AGENTS.md') + } expect(derivedText(agent)).not.toContain('workspace-context:') } finally { await rm(root, { recursive: true, force: true }) From eb9d2a7ea0f449b26fb3a9b8e5bd6d7db8844db9 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:57:08 -0700 Subject: [PATCH 027/229] fix(workspace-context): preserve UTF-8 render proof --- .../2026-06-24-workspace-context.i18n.yaml | 4 +- .../feature/2026-06-24-workspace-context.md | 2 +- .../2026-06-24-workspace-context.zh.md | 2 +- .../workspace-context/README.i18n.yaml | 4 +- packages/context/workspace-context/README.md | 2 +- .../context/workspace-context/README.zh.md | 2 +- .../context/workspace-context/src/render.ts | 41 ++++++++++--------- .../tests/workspace-context.spec.ts | 28 ++++++++++++- 8 files changed, 56 insertions(+), 29 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 71a11a4666..e07fa2dc8e 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-24-workspace-context.md -2026-06-24-workspace-context.md: 004792bbbcf6a99e2c5cdcd4c4d640d9c7d39877 -2026-06-24-workspace-context.zh.md: a7d0ea1884c8cb69c96e5192c0fbc1d2ae5ea4dd +2026-06-24-workspace-context.md: c7b8d5eb11534b9c0cba1865743d9ff195c4a8e3 +2026-06-24-workspace-context.zh.md: 49a4fb8f3957f692ae24617222ad0fed2ad24a20 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 004792bbbc..c7b8d5eb11 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -52,7 +52,7 @@ Every workspace context event stores versioned metadata with `{ action, scope, p At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `user/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy. -An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. A change enters metadata or pending state only when its file-specific section retains at least one content byte, or when the original content is genuinely empty. Partial truncation commits the full-content digest once any byte survives; zero-content truncation remains eligible on a later touch. +An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. A change enters metadata or pending state only when its file-specific section retains at least one content byte, or when the original content is genuinely empty. Partial truncation commits the full-content digest once any byte survives; zero-content truncation remains eligible on a later touch. A baseline may retain budget diagnostics with no committed changes. A dynamic batch with no committed change is withheld entirely and retried on a later touch. The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. A later successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends baseline edits or removals as dynamic messages; it never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the first resumed request. diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index a7d0ea1884..49a4fb8f39 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -52,7 +52,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, 协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `user/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。 -路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入元数据或待处理状态。只要任一字节保留下来,部分截断就会提交完整内容 digest;零内容截断仍可在后续触碰中处理。 +路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入元数据或待处理状态。只要任一字节保留下来,部分截断就会提交完整内容 digest;零内容截断仍可在后续触碰中处理。基线可以保留字节预算诊断而不提交任何变更。动态批次若没有可提交变更,则整批不注入,并在后续触碰时重试。 只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。后续成功的文件系统触碰会在压缩后重新添加未变化的基线 scope,或把基线编辑或移除操作追加为动态消息;它绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。恢复时准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。 diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 85980bfcf0..87c524a67a 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/workspace-context/README.md -README.md: dc8889cf99b1af691c72af621e78dfb93228ad92 -README.zh.md: ef1dd31ecd5b368aa3e92176edda5aa53268f7d8 +README.md: 0be9ea1af7205f39cf9479ed155c4ae9910041ca +README.zh.md: d1fbb073d686bef1e2dd65f37fd5df689f592d50 diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index dc8889cf99..0be9ea1af7 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -50,7 +50,7 @@ The plugin owns the complete `` framing, and every injected `us Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; the complete startup or resume baseline also carries `baseline: true`. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy. -An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. A model-visible change enters the source, pending state, and version cache only when its file-specific section retains at least one content byte, or when its original content is genuinely empty. Partial truncation records the complete-content digest once any content byte survives; truncation to zero remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. +An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. A model-visible change enters the source, pending state, and version cache only when its file-specific section retains at least one content byte, or when its original content is genuinely empty. Partial truncation records the complete-content digest once any content byte survives; truncation to zero remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. A baseline may still publish its budget diagnostic with an empty change list. A dynamic batch with no committed change is not injected at all, and a later touch retries it. The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface; the next successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it injects a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes before its first request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop prepares its baseline. diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index ef1dd31ecd..d1fbb073d6 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -50,7 +50,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when 模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整的启动或恢复基线还会携带 `baseline: true`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 -路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。模型可见变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入来源、pending 状态和版本 cache。只要任一内容字节保留下来,部分截断就会记录完整内容的 digest;截断到零字节则仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 +路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。模型可见变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入来源、pending 状态和版本 cache。只要任一内容字节保留下来,部分截断就会记录完整内容的 digest;截断到零字节则仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。基线即使带空变更列表,仍可发布字节预算诊断。动态批次若没有可提交变更,则完全不注入,并在后续 touch 时重试。 初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态;下一次成功的文件系统 touch 会在压缩后重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在恢复 loop 准备基线时可见。 diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index bd59decdb4..e07b72cc91 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -69,18 +69,16 @@ function byteLength(value: string): number { return Buffer.byteLength(value, 'utf8') } -function zeroContentTruncatedPaths(truncated: TruncatedInstruction[]): Set { - return new Set(truncated - .filter(item => item.originalBytes > 0 && item.includedBytes === 0) - .map(item => item.displayPath)) -} - function truncateUtf8(value: string, maxBytes: number): string { - let truncated = Buffer.from(value, 'utf8').subarray(0, Math.max(0, maxBytes)).toString('utf8') - while (byteLength(truncated) > maxBytes) { - truncated = truncated.slice(0, -1) + const bytes = Buffer.from(value, 'utf8') + if (bytes.length <= maxBytes) return value + let end = Math.max(0, Math.trunc(maxBytes)) + // If the first excluded byte is a UTF-8 continuation byte, the budget cut + // through that code point. Back up to its lead byte and exclude it too. + while (end > 0 && (bytes.readUInt8(end) & 0xc0) === 0x80) { + end -= 1 } - return truncated + return bytes.subarray(0, end).toString('utf8') } function escapeInstructionFrameBody(body: string): string { @@ -199,12 +197,10 @@ export function renderInstructionChanges( } const rendered = renderInstructionContext(items.map(item => item.file), maxBytes, style) const represented = new Set(rendered.represented.map(file => file.absolutePath)) - const contentOmitted = zeroContentTruncatedPaths(rendered.truncated) return { text: rendered.text, changes: items - .filter(item => represented.has(item.file.absolutePath) - && !contentOmitted.has(item.file.displayPath)) + .filter(item => represented.has(item.file.absolutePath)) .map(item => item.change), } } @@ -294,21 +290,26 @@ function renderInstructionContext( /* v8 ignore next -- callers only reach this after a non-empty fullText was built. */ if (mostSpecific === undefined) return { text: '', omitted: [], truncated: [], represented: [] } const omitted = files.slice(0, -1).map(file => ({ absolutePath: file.absolutePath, displayPath: file.displayPath })) + const originalBytes = byteLength(mostSpecific.content) for (const candidateStyle of [style, { ...style, intro: COMPACT_WORKSPACE_CONTEXT_INTRO }]) { const truncatedFile = truncateToFit(mostSpecific, [], maxBytes, omitted, candidateStyle) + const includedBytes = byteLength(truncatedFile.content) const truncated = [{ displayPath: mostSpecific.displayPath, - originalBytes: byteLength(mostSpecific.content), - includedBytes: byteLength(truncatedFile.content), + originalBytes, + includedBytes, }] const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle) - if (byteLength(text) <= maxBytes) return { text, omitted, truncated, represented: [mostSpecific] } + if (byteLength(text) <= maxBytes) { + const represented = includedBytes > 0 || originalBytes === 0 ? [mostSpecific] : [] + return { text, omitted, truncated, represented } + } } const truncated = [{ displayPath: mostSpecific.displayPath, - originalBytes: byteLength(mostSpecific.content), + originalBytes, includedBytes: 0, }] const compactNotice = escapeInstructionFrameBody(markerText(maxBytes, omitted, truncated)) @@ -316,7 +317,8 @@ function renderInstructionContext( [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'), ) if (byteLength(compactWithHeading) <= maxBytes) { - return { text: compactWithHeading, omitted, truncated, represented: [mostSpecific] } + const represented = originalBytes === 0 ? [mostSpecific] : [] + return { text: compactWithHeading, omitted, truncated, represented } } const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes) return { text, omitted, truncated, represented: [] } @@ -334,8 +336,7 @@ export function renderWorkspaceInstructionSet( options: { maxBytes: number }, ): RenderedInstructionSet { const { represented, ...rendered } = renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE) - const contentOmitted = zeroContentTruncatedPaths(rendered.truncated) - return { rendered, included: represented.filter(file => !contentOmitted.has(file.displayPath)) } + return { rendered, included: represented } } /** diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index f8a375aa87..e4320fa52c 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -41,7 +41,7 @@ import { type InstructionVersionCache, type PendingInstructionChange, } from '../src/state.ts' -import { candidateScopeKey, renderInstructionChanges } from '../src/render.ts' +import { candidateScopeKey, renderInstructionChanges, renderWorkspaceInstructionSet } from '../src/render.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** Per-candidate reconciliation scope key: directory paired with the file name. */ @@ -818,6 +818,15 @@ describe('workspace context rendering', () => { expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(120) }) + it('represents a genuinely empty instruction when its compact heading fits', () => { + const file = { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: '' } + const rendered = renderWorkspaceInstructionSet([file], { maxBytes: 117 }) + + expect(rendered.rendered.text).toContain('truncated pkg/AGENTS.md from 0 to 0 bytes') + expect(rendered.rendered.text).toContain('Instructions from: pkg/AGENTS.md') + expect(rendered.included).toEqual([file]) + }) + it('truncates the compact notice itself when the render budget is smaller than the notice', () => { const rendered = renderWorkspaceContext([ { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) }, @@ -881,6 +890,23 @@ describe('workspace context rendering', () => { expect(rendered.changes).toEqual([]) }) + it('does not commit a multibyte change when the budget cuts its first code point', () => { + const change = { + action: 'set' as const, + scope: sk('pkg', 'AGENTS.md'), + path: 'pkg/AGENTS.md', + digest: 'digest', + } + const rendered = renderInstructionChanges([{ + change, + file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: '😀'.repeat(100) }, + }], 366) + + expect(rendered.text).not.toContain('�') + expect(rendered.text).not.toContain('😀') + expect(rendered.changes).toEqual([]) + }) + it('keeps compact truncation notices within budget when a multibyte display path is cut', () => { const rendered = renderWorkspaceContext([ { absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) }, From 0fa0405deb32f373d9728e0c7211a0fd9a2a7d50 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:57:57 -0700 Subject: [PATCH 028/229] test(workspace-context): pin empty rendered changes --- .../context/workspace-context/src/render.ts | 8 +++++--- .../tests/workspace-context.spec.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index e07b72cc91..c54666698b 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -30,9 +30,11 @@ export interface RenderedWorkspaceContext { interface RenderedInstructionContext extends RenderedWorkspaceContext { /** - * Original files whose file-specific section text survived rendering. This - * is not the complement of `omitted`: a truncated file may be represented - * here and in `truncated`, while a notice-only file appears in neither. + * Original files semantically represented by rendered section text. This is + * not the complement of `omitted`: a truncated file may be represented here + * and in `truncated`, while a notice-only file appears in neither. A genuinely + * empty file counts when its heading survives because that heading conveys + * that the instruction exists and has no content. */ represented: LoadedInstructionFile[] } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index e4320fa52c..ebaaf3a89a 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -827,6 +827,24 @@ describe('workspace context rendering', () => { expect(rendered.included).toEqual([file]) }) + it('represents a genuinely empty instruction through the framed compact-intro path', () => { + const file = { + absolutePath: '/repo/pkg/AGENTS.md', + displayPath: 'pkg/AGENTS.md', + content: '', + } + + const rendered = renderWorkspaceInstructionSet([file], { maxBytes: 300 }) + + expect(rendered.rendered.text).toContain('') + expect(rendered.rendered.text).toContain('Workspace instructions were omitted or truncated') + expect(rendered.rendered.text).toContain('Instructions from: pkg/AGENTS.md') + expect(rendered.rendered.truncated).toEqual([ + { displayPath: 'pkg/AGENTS.md', originalBytes: 0, includedBytes: 0 }, + ]) + expect(rendered.included).toEqual([file]) + }) + it('truncates the compact notice itself when the render budget is smaller than the notice', () => { const rendered = renderWorkspaceContext([ { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) }, From 310087582321b08baaac0a2324d08dbdb47f8923 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 19:45:41 +0800 Subject: [PATCH 029/229] test(web): remove stale host description override --- packages/host/apiproxy/tests/fetch-carrier.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 5bc389e81c..29a93ba79b 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -1,7 +1,6 @@ import { CommandId } from '@deepseek-ai/dsh-commands/brand' import { describe, expect, it, vi } from 'vitest' import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts' -import type { ResponseValue } from '../src/api/rpc-map.ts' import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { toFetchHandler } from '../src/fetch/handler.ts' @@ -12,7 +11,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[] hostFrames: HostFrame[] crashOn: string - hostDescription: ResponseValue<'host.describe'> }> = {}): ApiProxy { const muxFrames = overrides.muxFrames ?? [{ type: 'session/subscribed', sessionId: 's1' as never, lastSeq: -1 }] const hostFrames = overrides.hostFrames ?? [{ type: 'host/session-removed', sessionId: 's1' as never }] @@ -98,7 +96,7 @@ function fakeApi(overrides: Partial<{ rpcId: request.rpcId, result: { ok: true, - value: overrides.hostDescription ?? { version: 'v', cwd: '/w', attachedSessions: 0 }, + value: { version: 'v', cwd: '/w', attachedSessions: 0 }, }, } }, From bbff6670009cb926bbad85578b8cdc9a595041e2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 19:49:33 +0800 Subject: [PATCH 030/229] test(apiproxy): drop the dead hostDescription fake override 363db89ba removed the client-side modality gate and the tests that fed host.describe a scripted activeModel, but left the fakeApi override field, its ResponseValue import, and the ?? fallback behind. Nothing passes hostDescription, so the left arm was unreachable. --- .../host/apiproxy/tests/fetch-carrier.spec.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 5bc389e81c..66c419688e 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -1,19 +1,13 @@ import { CommandId } from '@deepseek-ai/dsh-commands/brand' import { describe, expect, it, vi } from 'vitest' import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts' -import type { ResponseValue } from '../src/api/rpc-map.ts' import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { toFetchHandler } from '../src/fetch/handler.ts' import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts' /** Minimal in-memory ApiProxy: echoes rpcIds, scripts one frame per stream. */ -function fakeApi(overrides: Partial<{ - muxFrames: MuxFrame[] - hostFrames: HostFrame[] - crashOn: string - hostDescription: ResponseValue<'host.describe'> -}> = {}): ApiProxy { +function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFrame[]; crashOn: string }> = {}): ApiProxy { const muxFrames = overrides.muxFrames ?? [{ type: 'session/subscribed', sessionId: 's1' as never, lastSeq: -1 }] const hostFrames = overrides.hostFrames ?? [{ type: 'host/session-removed', sessionId: 's1' as never }] async function * stream(frames: F[], signal: AbortSignal): AsyncGenerator> { @@ -94,13 +88,7 @@ function fakeApi(overrides: Partial<{ }, host: { async describe(request) { - return { - rpcId: request.rpcId, - result: { - ok: true, - value: overrides.hostDescription ?? { version: 'v', cwd: '/w', attachedSessions: 0 }, - }, - } + return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } } }, async pickDirectory(request) { return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } } From 82df6dcc0b4d62b8f1ff4e0f8e3d473b20b05a2b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 20:33:11 +0800 Subject: [PATCH 031/229] fix(cli): avoid duplicate attachment backend --- apps/cli/config/web.cordis.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index 70ff2915bc..3d7fe06baf 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -80,9 +80,6 @@ # `dshClient` rows are the browser roster the modules node half scans into # window.__DSH_BOOT__; the modules row is simultaneously a host row. - insert: - - id: attachment-local - name: '@deepseek-ai/dsh-attachment-local' - - id: session-projection name: '@deepseek-ai/dsh-session-projection' From 8b00482d7ca816e8de9f9e2fff1e30e2f70c39d6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 17:15:08 +0800 Subject: [PATCH 032/229] fix(web): harden multimodal draft and storage lifecycle --- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 8 +-- ...-image-input-and-durable-attachments.zh.md | 8 +-- docs/config-catalog.md | 8 ++- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/README.zh.md | 2 +- .../attachment/attachment-local/src/store.ts | 54 ++++++++----------- .../attachment-local/tests/store.spec.ts | 22 ++++++-- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- packages/client/connection/src/index.ts | 18 ++++++- .../client/connection/tests/node-half.spec.ts | 16 +++++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 25 +++++---- .../src/client/chat/AssistantMarkdown.tsx | 9 ++-- .../src/client/chat/MessageImage.tsx | 21 ++++---- .../src/client/chat/MessageItem.tsx | 11 ++-- .../ui-conversation/src/client/input/hub.ts | 7 +-- .../ui-conversation/src/client/locales.ts | 28 ++++++++++ .../ui-conversation/src/client/service.ts | 15 +++++- .../src/client/skeleton/ImageLightbox.tsx | 12 +++-- .../src/client/skeleton/InputBar.tsx | 19 ++++--- .../tests/apply-inject.spec.tsx | 41 +++++++++++++- .../tests/message-image.spec.tsx | 17 ++++-- .../tests/service-orchestration.spec.ts | 40 +++++++++++--- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 12 +++-- .../apiproxy/tests/api-proxy-models.spec.ts | 5 ++ 34 files changed, 303 insertions(+), 129 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index dbf7d9ed1f..797625bc2c 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 1b0e9083e205bb69f8a3ee9e4c073af7864b6ed7 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 095420b09ea34e98002480a218f74bc9f8421876 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 6a0b109d4151207f89d38125eda2535c73e43057 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 4a3917ea2dae5252b4512c7fa707ef20cca8a7fd diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 1b0e9083e2..6a0b109d41 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -67,9 +67,9 @@ interface ComposerAttachment { } ``` -This split uses the session provide channel's input hook and actions as the single subscription path for live composer state while keeping non-serializable browser objects out of persisted JSON. Only the plain-text draft mirror uses `localStorage`; attachment identifiers, browser `File` objects, and object URLs remain scoped to the live session input shell. Unsent images therefore do not survive reload or session-scope disposal. A native client may stage input in an OS temporary directory, but it must treat that path exactly like the browser object URL: delete it when no longer needed and copy the bytes into the durable store before message acceptance. +This split uses the session provide channel's input hook and actions as the single subscription path for live composer state while keeping non-serializable browser objects out of persisted JSON. Only the plain-text draft mirror uses `localStorage`; attachment identifiers, browser `File` objects, and object URLs remain scoped to the live session input shell. Unsent images therefore do not survive reload or session-scope disposal. A Workspace switch moves a mixed text-and-image draft only when the destination shell accepts the complete image batch; refusal leaves both parts with the source. A native client may stage input in an OS temporary directory, but it must treat that path exactly like the browser object URL: delete it when no longer needed and copy the bytes into the durable store before message acceptance. -The local attachment backend resolves an explicit `dshHome`, then `$DSH_HOME`, then `~/.dsh`. It stores content-addressed objects below `$DSH_HOME/attachments/v1/objects//` with owner-only directory and file permissions. A temporary file is written, synchronized, atomically published, and made durable with a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier. Admission and reads fully decode supported rasters before accepting their format and dimensions, and every read also verifies the digest, byte length, and logged metadata. +The local attachment backend resolves an explicit `dshHome`, then `$DSH_HOME`, then `~/.dsh`. It stores content-addressed objects below `$DSH_HOME/attachments/v1/objects//` with owner-only directory and file permissions. On each process's first save for one home, it creates that home and synchronizes every ancestor entry to the filesystem root; existence is not treated as durability because another process may still be between `mkdir` and parent `fsync`. A temporary file is then written, synchronized, atomically published, and made durable with directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier. Admission and reads fully decode supported rasters before accepting their format and dimensions, and every read also verifies the digest, byte length, and logged metadata. The store performs no automatic deletion in version one. Sent user images and model-generated images remain reachable for history, resume, and fork. Reference-aware garbage collection needs a separate design because an age-only rule can delete data still referenced by a durable session. Deployment byte and pixel limits are admission policy on writes; reads verify the digest and recorded metadata without reapplying current admission limits, so lowering policy does not invalidate older history. @@ -122,7 +122,7 @@ Base64 crosses JSON-RPC once and is discarded after persistence. The host valida Model catalog entries gain optional merge-extensible input modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. -The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history; compaction can remove old images and make a later text-only selection valid, and an admission that ends idle without publication releases the gate. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. +The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. @@ -140,7 +140,7 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state ### Limits and trust boundaries -Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier independently caps buffered API request bodies, deriving the cap from the host attachment service's aggregate image limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. +Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (32 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end. Malformed base64, unsupported or mismatched media, truncated image payloads, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index 095420b09e..4a3917ea2d 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -67,9 +67,9 @@ interface ComposerAttachment { } ``` -这一拆分把会话 provide 通道的输入 hook 与 actions 用作实时输入区状态的唯一订阅路径,同时避免把不可序列化的浏览器对象写进持久 JSON。只有纯文本草稿镜像使用 `localStorage`;附件标识符、浏览器 `File` 对象和对象 URL 都限定在实时会话输入外壳的 scope 内。未发送图片因此无法跨重载或会话 scope 释放保留。原生客户端可以在操作系统临时目录中暂存输入,但必须像对待浏览器对象 URL 一样对待该路径:不再需要时删除,并在消息被接受前把字节复制进持久存储。 +这一拆分把会话 provide 通道的输入 hook 与 actions 用作实时输入区状态的唯一订阅路径,同时避免把不可序列化的浏览器对象写进持久 JSON。只有纯文本草稿镜像使用 `localStorage`;附件标识符、浏览器 `File` 对象和对象 URL 都限定在实时会话输入外壳的 scope 内。未发送图片因此无法跨重载或会话 scope 释放保留。切换 Workspace 时,只有目标外壳接受完整图片批次,图文混合草稿才会移动;拒绝时,文本和图片都留在来源外壳。原生客户端可以在操作系统临时目录中暂存输入,但必须像对待浏览器对象 URL 一样对待该路径:不再需要时删除,并在消息被接受前把字节复制进持久存储。 -本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。服务先写入并同步临时文件,再以原子方式发布,并对发布目录执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中。写入准入与读取都会完整解码受支持的光栅图片,之后才接受其格式和尺寸;每次读取还会校验摘要、字节长度和已记录的元数据。 +本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。每个进程首次为某个 home 保存对象时,都会创建该 home,并逐级同步每个祖先目录项直至文件系统根目录;不能把存在视为持久性,因为另一个进程可能仍处于 `mkdir` 与父目录 `fsync` 之间。随后,服务写入并同步临时文件,再以原子方式发布,并对发布路径执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中。写入准入与读取都会完整解码受支持的光栅图片,之后才接受其格式和尺寸;每次读取还会校验摘要、字节长度和已记录的元数据。 第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。部署的字节和像素限制是写入时的准入策略;读取时会校验摘要和已记录的元数据,但不重新应用当前准入限制,因此收紧策略不会导致旧历史记录失效。 @@ -122,7 +122,7 @@ Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规 模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标;压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效,而未发布任何事件即转入空闲的准入会释放该门槛。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 @@ -140,7 +140,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme ### 限制与信任边界 -第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体会独立限制 API 请求体的缓冲大小,并根据宿主附件服务的图片总量限制,加上 base64 和请求封装的膨胀量推导上限;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 32 MiB);如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2534ce4940..22d2576ff7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -313,10 +313,16 @@ export interface ConnectionConfig { * that is not a bare, canonical authority fails the plugin load. */ trustedHosts?: string[] + /** + * Maximum buffered JSON body for every `/api` request. This carrier policy + * is independent of image limits but must be large enough for the configured + * aggregate image bytes after base64 and envelope expansion. + */ + maxRequestBodyBytes?: number } ``` -Source: [`packages/client/connection/src/index.ts:24`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:26`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 77b716173f..daa65c2d38 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-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/attachment/attachment-local/README.md -README.md: 310120bd1c3573da1e7c60334d5c2712195f3186 -README.zh.md: 9fd3a857eca1c25c90b665735f67d2c27c92334a +README.md: 80001b29b392fe1c8b663f46d47f1ec0726e6d0f +README.zh.md: c3b95ace06b9f5ada156f20f33a1740a235400aa diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 310120bd1c..80001b29b3 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. +The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 9fd3a857ec..c3b95ace06 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布目录执行同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。 +这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。 diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 809cbfe53d..bd33e4c8e3 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -2,8 +2,8 @@ import { createHash, randomUUID } from 'node:crypto' import { constants } from 'node:fs' -import { chmod, link, mkdir, open, readFile, stat, unlink } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' +import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises' +import { dirname, join, parse, resolve } from 'node:path' import { AttachmentError, AttachmentId, @@ -17,6 +17,7 @@ import type { import { detectImage, probeImage } from './image.ts' const ID_PATTERN = /^sha256:([a-f0-9]{64})$/ +const durableHomes = new Set() function digest(data: Uint8Array): string { return createHash('sha256').update(data).digest('hex') @@ -83,31 +84,6 @@ async function syncDirectory(path: string): Promise { } } -/** - * Walk up from a preferred boundary to the deepest ancestor that already - * exists. A first save may create DSH_HOME itself (recursive mkdir), and a - * directory this process creates is not durable until its parent entry syncs - * — so only a pre-existing directory may be vouched as the durable stop. - * @param path - preferred absolute boundary. - * @returns `path` when it exists, else its closest existing ancestor. - */ -async function existingBoundary(path: string): Promise { - let level = resolve(path) - while (true) { - try { - await stat(level) - return level - } catch { - // Swallows only the stat probe's failure: a missing (or unreadable) - // level simply moves the boundary up; mkdir later surfaces real errors. - } - const parent = dirname(level) - /* v8 ignore next -- filesystem-root guard: the root directory always exists, so stat returns first. */ - if (parent === level) return level - level = parent - } -} - /** * Create one private directory tree and persist every ancestor entry up to a * caller-vouched durable boundary. The walk deliberately ignores what mkdir @@ -134,6 +110,20 @@ async function ensureDurableDirectory(path: string, boundary: string): Promise { + const home = resolve(path) + if (!durableHomes.has(home)) { + await ensureDurableDirectory(home, parse(home).root) + durableHomes.add(home) + } + return home +} + /** * Save and verify immutable image bytes below a versioned attachment root. * @param root - absolute `DSH_HOME/attachments/v1` root. @@ -147,12 +137,10 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li const sha256 = digest(input.data) const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') - // The preferred boundary is the root's grandparent (DSH_HOME for the - // documented `DSH_HOME/attachments/v1` layout): `attachments`/`v1` may be - // first-created by a concurrent save, so their entries sync on every path. - // When DSH_HOME itself does not exist yet, the boundary retreats to its - // closest existing ancestor so the first save syncs the new home entry too. - const boundary = await existingBoundary(dirname(dirname(resolve(root)))) + // Establish DSH_HOME itself against the filesystem root once per process. + // Every process performs that proof independently, so observing a directory + // another process created can never be mistaken for durable publication. + const boundary = await ensureDurableHome(dirname(dirname(resolve(root)))) await ensureDurableDirectory(bucket, boundary) await ensureDurableDirectory(staging, boundary) const temporary = join(staging, randomUUID()) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 159c4e20b8..2332bfe942 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto' import { constants } from 'node:fs' import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join, parse, resolve } from 'node:path' import { mkdtemp, rm } from 'node:fs/promises' import { afterEach, describe, expect, it, vi } from 'vitest' import sharp from 'sharp' @@ -43,6 +43,17 @@ async function root(): Promise { return join(value, 'attachments', 'v1') } +function parentChainToRoot(path: string): string[] { + const parents: string[] = [] + let level = resolve(path) + const root = parse(level).root + while (level !== root) { + level = dirname(level) + parents.push(level) + } + return parents +} + afterEach(async () => { await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))) }) @@ -58,10 +69,11 @@ describe('local attachment store', () => { await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) - // Every level between each created directory and the vouched boundary - // syncs unconditionally — "already existed" is not "already durable" - // when a concurrent first save may have created but not yet synced it. + // Each process first proves DSH_HOME durable all the way to the filesystem + // root; existence alone cannot vouch for a concurrent creator's fsync. + // Later directory creation can then stop at that process-proven boundary. expect(fsControl.syncedDirectories).toEqual([ + ...parentChainToRoot(base), // bucket chain: every parent entry between the bucket and the boundary. objects, storageRoot, @@ -77,7 +89,7 @@ describe('local attachment store', () => { ]) }) - it('retreats the durable boundary to the closest existing ancestor when the home directory does not exist yet', async () => { + it('creates and persists a missing nested home directory against the filesystem root', async () => { const storageRoot = join(await root(), 'home', 'attachments', 'v1') const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 14c1c7fd94..b2a87f4885 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 8b4a8fddc2fdc48873a4f93c99fce62a3dd17766 -README.zh.md: cac04f5735f30498a301fb1a70748f8d7426dfbf +README.md: 8e8cb23cf56745116142d786ad712d7230f4f2c5 +README.zh.md: dc2416d8b4dafa06f715e60f97e6dcd6d7fc29d9 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 8b4a8fddc2..8e8cb23cf5 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Each successful generation validates `host.describe` before `onConnected`; a business-error response fails the generation like a transport error. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Each successful generation validates `host.describe` before `onConnected`; a business-error response fails the generation like a transport error. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. Its independent `maxRequestBodyBytes` config caps every buffered API request (32 MiB default) and must be large enough for the configured aggregate image payload after base64 and envelope expansion; changing image policy does not silently redefine the carrier cap for text and other methods. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index cac04f5735..dc2416d8b4 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。每个成功连接代都会在 `onConnected` 前校验 `host.describe`;业务错误响应会像传输错误一样使该连接代失败。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。每个成功连接代都会在 `onConnected` 前校验 `host.describe`;业务错误响应会像传输错误一样使该连接代失败。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。其独立的 `maxRequestBodyBytes` 配置会限制每个缓冲 API 请求(默认 32 MiB),并且必须足以容纳配置的图片总载荷经 base64 和请求封装膨胀后的大小;修改图片策略不会静默地为文本和其他方法重定义载体上限。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 48eca36020..e0fa386057 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -16,6 +16,8 @@ export const name = 'client-connection' /** Headroom for RPC JSON fields around the aggregate base64 image payload. */ const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024 +/** Independent default carrier cap; deployments may raise it for larger valid non-image RPCs. */ +const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024 /** Services required before mounting the route. */ export const inject = ['httpServer', 'apiProxy', 'attachments'] @@ -31,10 +33,17 @@ export interface ConnectionConfig { * that is not a bare, canonical authority fails the plugin load. */ trustedHosts?: string[] + /** + * Maximum buffered JSON body for every `/api` request. This carrier policy + * is independent of image limits but must be large enough for the configured + * aggregate image bytes after base64 and envelope expansion. + */ + maxRequestBodyBytes?: number } export const Config: z = z.object({ trustedHosts: z.array(String).default([]), + maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES), }) /** @@ -80,9 +89,16 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { // silently authorizing its hostname prefix at request time. for (const entry of trustedHosts) assertTrustedAuthority(entry) const apiHandler = toFetchHandler(ctx.apiProxy) - const maxRequestBodyBytes = Math.ceil( + const requiredImageBodyBytes = Math.ceil( ctx.attachments.imageLimits.maxMessageImageBytes * 4 / 3, ) + REQUEST_ENVELOPE_HEADROOM_BYTES + const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES + if (maxRequestBodyBytes < requiredImageBodyBytes) { + throw new Error( + `client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least ` + + `${String(requiredImageBodyBytes)} for the configured aggregate image limit`, + ) + } const route: WebRoute = { kind: 'prefix', path: API_PATH, diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 98842779c2..3a292d39ad 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -51,7 +51,10 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b return { response, state } } -async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise }> { +async function mounted(config?: { + trustedHosts?: string[] + maxRequestBodyBytes?: number +}): Promise<{ routes: WebRoute[]; dispose: () => Promise }> { const ctx = new Context() const routes: WebRoute[] = [] ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) @@ -96,6 +99,17 @@ describe('connection node half', () => { expect(routes).toHaveLength(0) }) + it('fails loud when the independent carrier cap cannot hold the configured image batch', () => { + const ctx = new Context() + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) + ctx.provide('attachments', fakeAttachments()) + expect(() => { apply(ctx, { maxRequestBodyBytes: 1024 }) }) + .toThrow(/must be at least .* aggregate image limit/) + expect(routes).toHaveLength(0) + }) + it('refuses an untrusted Host on any /api path before the bridge runs', async () => { const { routes, dispose } = await mounted() const { response, state } = fakeResponse() diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 12fe2cb835..0e5dfd698c 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: b87d562d9faed9beeb7881f1cf037ce75a046dde -README.zh.md: c2dbec5987fef987d5d89627259a1769ee0a29f3 +README.md: 119984642fb668b6b07dd1eddc5a58e0681906c9 +README.zh.md: 3aed3fdfa0cf5a46780289df96bd975b357835a0 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b87d562d9f..119984642f 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -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. 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 occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). 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 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. 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; a mixed text-and-image draft moves only when the destination accepts the complete image batch, otherwise both parts stay with the source. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). 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 conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c2dbec5987..3aed3fdfa0 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -4,7 +4,7 @@ 会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store;只有目标接受完整图片批次,图文混合草稿才会移动,否则文本和图片都留在来源端。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 3a0b3ce2e2..c21362ac69 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -13,7 +13,7 @@ import type { import type { InputNotice } from './input/contract.ts' import { resolveToolPath } from './contract/tool-call-model.ts' import { createChatStore } from './stores.ts' -import { ConversationService } from './service.ts' +import { ConversationService, UnsupportedImageMediaTypeError } from './service.ts' import type { IConversation } from './service.ts' import { InputHub } from './input/hub.ts' import { InputBar } from './skeleton/InputBar.tsx' @@ -158,15 +158,17 @@ export function apply(ctx: Context): void { const draft = from.snapshot.draft const imageIds = from.snapshot.imageIds const next = inputHub.shell(nextId) - if (draft !== '') { - next.setDraft(draft) - from.setDraft('') - } // Transfer only on acceptance: a destination shell mid-submission - // refuses, and the drafts must stay owned (and releasable) by the - // source shell instead of silently leaking their object URLs. - if (imageIds.length > 0 && next.addImages(imageIds)) { - for (const id of imageIds) from.removeImage(id) + // refuses the whole mixed draft, which remains owned (and releasable) + // by the source shell instead of splitting text from its images. + if (imageIds.length === 0 || next.addImages(imageIds)) { + if (draft !== '') { + next.setDraft(draft) + from.setDraft('') + } + if (imageIds.length > 0) { + for (const id of imageIds) from.removeImage(id) + } } } sessions.open(nextId) @@ -242,6 +244,11 @@ export function apply(ctx: Context): void { } return null } catch (error: unknown) { + if (error instanceof UnsupportedImageMediaTypeError) { + return t('image.unsupportedType', { + type: error.mediaType || t('image.unknownType'), + }) + } return error instanceof Error ? error.message : String(error) } }, diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index ea6a2f4292..5c44f89819 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -71,8 +71,9 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, loadImage = unavailableImage, time, seq, onFork, t, + blocks, streaming, interrupted, loadImage, time, seq, onFork, t, }: AssistantMarkdownProps) { + const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable')))) // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) @@ -95,7 +96,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ ) case 'reasoning': return - case 'image': return + case 'image': return // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. case 'tool-call': return null default: return ( @@ -123,7 +124,3 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
) }) - -function unavailableImage(): Promise { - return Promise.reject(new Error('图片读取服务不可用')) -} diff --git a/packages/client/ui-conversation/src/client/chat/MessageImage.tsx b/packages/client/ui-conversation/src/client/chat/MessageImage.tsx index e3bba25b8f..3f22ff72b8 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageImage.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageImage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { ChatViewSlotProps } from '../contract/slots.ts' import { ImageLightbox } from '../skeleton/ImageLightbox.tsx' import css from './MessageImage.module.css' @@ -7,9 +8,10 @@ import css from './MessageImage.module.css' export type ImageLoader = (attachment: ImageAttachmentRef) => Promise /** Compact history renderer with retryable loading and double-click original preview. */ -export function MessageImage({ attachment, load }: { +export function MessageImage({ attachment, load, t }: { attachment: ImageAttachmentRef load: ImageLoader + t: ChatViewSlotProps['t'] }) { const [src, setSrc] = useState(null) const [error, setError] = useState(false) @@ -33,36 +35,37 @@ export function MessageImage({ attachment, load }: { return () => { live = false } }, [attachment, load]) - const label = attachment.name ?? '图片' - if (error) return + const label = attachment.name ?? t('image.label') + if (error) return return ( <> - {open && src !== null && } + {open && src !== null && } ) } /** Wrapping image group shared by user and assistant history. */ -export function ImageGallery({ images, load, align }: { +export function ImageGallery({ images, load, align, t }: { images: readonly { attachment: ImageAttachmentRef }[] load: ImageLoader align: 'start' | 'end' + t: ChatViewSlotProps['t'] }) { if (images.length === 0) return null return (
{images.map((image, index) => ( - + ))}
) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 35ee8fcfd5..80dbbc75ee 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -152,8 +152,9 @@ function projectUserText(text: string): ReactNode { } export const MessageItem = memo(function MessageItem({ - node, loadImage = unavailableImage, retryActive = false, onFork, t, + node, loadImage, retryActive = false, onFork, t, }: MessageItemProps) { + const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable')))) const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { case 'user': { @@ -161,7 +162,7 @@ export const MessageItem = memo(function MessageItem({ return (
- + {(text !== '' || rest.length > 0) &&
{projectUserText(text)} {rest.map((block, i) => ( @@ -186,7 +187,7 @@ export const MessageItem = memo(function MessageItem({ return (
- +
{t('message.steering')} {projectUserText(text)} @@ -212,7 +213,3 @@ export const MessageItem = memo(function MessageItem({ ) } }) - -function unavailableImage(): Promise { - return Promise.reject(new Error('图片读取服务不可用')) -} diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 92733ccbcc..b040a27b1a 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -162,12 +162,7 @@ export class InputHub implements InputService { // see them — release the drafts here instead of resurrecting them onto // a dead instance where they would leak for the page lifetime. if (this.shells.get(session.sessionId) === shell) { - if (shell?.snapshot.imageIds.length === 0) { - shell.restoreImages(imageIds) - } else { - const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined - for (const id of imageIds) conversation?.releaseDraftImage(id) - } + shell?.restoreImages(imageIds) if (shell?.snapshot.draft === '') shell.setDraft(text) return } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 1a9c7a1f34..cde430dd83 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -23,6 +23,20 @@ export const zh = { 'input.stop': '停止生成', 'input.send': '发送消息', 'input.accessMode': '访问模式,当前:{name}', + 'image.dropHint': '松开以添加图片', + 'image.pending': '待发送图片', + 'image.openOriginal': '双击查看原图', + 'image.openOriginalLabel': '{label},双击查看原图', + 'image.remove': '移除图片 {name}', + 'image.original': '原图', + 'image.label': '图片', + 'image.loadFailed': '图片加载失败,点击重试', + 'image.loading': '图片加载中…', + 'image.preview': '原图预览', + 'image.closePreview': '关闭原图预览', + 'image.serviceUnavailable': '图片读取服务不可用', + 'image.unsupportedType': '不支持的图片格式:{type}', + 'image.unknownType': '未知格式', 'access.confirm.title': '确认启用 Full access?', 'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', 'access.confirm.acknowledge': '我已了解风险,并愿意继续', @@ -117,6 +131,20 @@ export const en = { 'input.stop': 'Stop generating', 'input.send': 'Send message', 'input.accessMode': 'Access mode, current: {name}', + 'image.dropHint': 'Drop to add images', + 'image.pending': 'Pending images', + 'image.openOriginal': 'Double-click to view original', + 'image.openOriginalLabel': '{label}, double-click to view original', + 'image.remove': 'Remove image {name}', + 'image.original': 'Original image', + 'image.label': 'Image', + 'image.loadFailed': 'Image failed to load; click to retry', + 'image.loading': 'Loading image…', + 'image.preview': 'Original image preview', + 'image.closePreview': 'Close original image preview', + 'image.serviceUnavailable': 'Image loading service unavailable', + 'image.unsupportedType': 'Unsupported image format: {type}', + 'image.unknownType': 'unknown format', 'access.confirm.title': 'Enable Full access?', 'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.', 'access.confirm.acknowledge': 'I understand the risks and want to continue', diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 3551f9252a..ee305a9ff1 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -63,6 +63,19 @@ interface ImageUrlEntry { readonly pending: Promise } +/** Unsupported browser-declared image type, localized by the UI boundary. */ +export class UnsupportedImageMediaTypeError extends Error { + /** Browser-declared MIME value, possibly empty. */ + readonly mediaType: string + + /** @param mediaType - Browser-declared MIME value, possibly empty. */ + constructor(mediaType: string) { + super(`unsupported image media type: ${mediaType || '(empty)'}`) + this.name = 'UnsupportedImageMediaTypeError' + this.mediaType = mediaType + } +} + /** Scope-addressed conversation service (root singleton, provided as `conversation`). */ export class ConversationService extends Service implements IConversation { /** The per-session input machine registry (InputService face, design §5.2). */ @@ -310,7 +323,7 @@ function imageMediaType(value: string): ImageMediaType { case 'image/gif': return value default: - throw new Error(`不支持的图片格式:${value || '未知格式'}`) + throw new UnsupportedImageMediaTypeError(value) } } diff --git a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx index 21c7f9799b..43cbc441a5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx @@ -1,8 +1,14 @@ import { useEffect, useRef } from 'react' +import type { ChatViewSlotProps } from '../contract/slots.ts' import css from './ImageLightbox.module.css' /** Document-level original-image preview opened by an explicit double-click. */ -export function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) { +export function ImageLightbox({ src, alt, onClose, t }: { + src: string + alt: string + onClose: () => void + t: ChatViewSlotProps['t'] +}) { const closeRef = useRef(null) const restoreRef = useRef(null) @@ -24,11 +30,11 @@ export function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; className={css.backdrop} role="dialog" aria-modal="true" - aria-label="原图预览" + aria-label={t('image.preview')} onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }} > {alt} - +
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 1f16e51112..b5dba5df55 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -478,25 +478,25 @@ export function InputBar({ onDragLeave={onDragLeave} onDrop={onDrop} > - {dragActive &&
松开以添加图片
} + {dragActive &&
{t('image.dropHint')}
} {overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} {attachments.length > 0 && ( -
+
{attachments.map(attachment => (
- {preview !== null && } + {preview !== null && ( + + )} {footer}
) diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 9806850db2..c9e6957f36 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -23,6 +23,7 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { DraftAttachmentId } from '../src/client/input/contract.ts' import type { createChatStore } from '../src/client/stores.ts' const ROOT = 'root-1' as SessionId @@ -96,11 +97,12 @@ async function bench() { const inputSurface = (id: SessionId) => { const info = runtime.sessions.provideInfo(id)! const state = info.hooks['input'] as { - getSnapshot: () => { draft: string } + getSnapshot: () => { draft: string; imageIds: readonly DraftAttachmentId[] } subscribe: (fn: () => void) => () => void } const actions = info.props['inputActions'] as { setDraft: (text: string) => void + addImages: (ids: readonly DraftAttachmentId[]) => boolean submit: (mode?: 'queue' | 'steer') => void } return { state, actions } @@ -108,7 +110,7 @@ async function bench() { return { runtime, feature, slots: runtime.slots, entryOf, conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface, - sessionFake, layoutFake, + sessionFake, layoutFake, locale, } } @@ -288,6 +290,41 @@ describe('conversation slot inject surface', () => { await b.runtime.dispose() }) + it('keeps a mixed draft together when the destination refuses its images', async () => { + const b = await bench() + const OTHER = 'mixed-target' as SessionId + await b.runtime.sessions.add({ id: OTHER }, { current: false }) + const source = b.inputSurface(ROOT) + const destination = b.inputSurface(OTHER) + const imageId = 'draft-mixed' as DraftAttachmentId + source.actions.setDraft('carry together') + source.actions.addImages([imageId]) + const destinationShell = b.composerSurface(OTHER).keyboard as unknown as { + addImages: (ids: readonly DraftAttachmentId[]) => boolean + } + vi.spyOn(destinationShell, 'addImages').mockReturnValue(false) + b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER)) + + await b.residentSurface(ROOT).selectWorkspace('workspace-mixed' as never) + + expect(source.state.getSnapshot()).toMatchObject({ + draft: 'carry together', + imageIds: [imageId], + }) + expect(destination.state.getSnapshot()).toMatchObject({ draft: '', imageIds: [] }) + await b.runtime.dispose() + }) + + it('localizes browser image-type rejection through the active conversation locale', async () => { + const b = await bench() + b.locale.setLocale('en') + const error = b.composerSurface(ROOT).addImages?.([ + new File([Uint8Array.of(1)], 'vector.svg', { type: 'image/svg+xml' }), + ]) + expect(error).toBe('Unsupported image format: image/svg+xml') + await b.runtime.dispose() + }) + it('scopedConversation fails loud when the session resolves no scope', async () => { const b = await bench() // The chat-view inject resolves the scoped conversation service at inject diff --git a/packages/client/ui-conversation/tests/message-image.spec.tsx b/packages/client/ui-conversation/tests/message-image.spec.tsx index 0b521a0f2a..6da4d42f12 100644 --- a/packages/client/ui-conversation/tests/message-image.spec.tsx +++ b/packages/client/ui-conversation/tests/message-image.spec.tsx @@ -7,11 +7,12 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { MessageImage } from '../src/client/chat/MessageImage.tsx' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' -import { zh } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' afterEach(cleanup) const t = makeTranslate(zh, commonZh) +const enT = makeTranslate(en, commonZh) const attachment = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), @@ -25,7 +26,7 @@ const attachment = { describe('MessageImage', () => { it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => { const load = vi.fn().mockResolvedValue('blob:history') - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,双击查看原图' }) expect(frame.getAttribute('style')).toContain('width: 240px') expect(frame.getAttribute('style')).toContain('height: 120px') @@ -41,13 +42,23 @@ describe('MessageImage', () => { const load = vi.fn() .mockRejectedValueOnce(new Error('offline')) .mockResolvedValueOnce('blob:retry') - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) fireEvent.click(retry) await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) expect(load).toHaveBeenCalledTimes(2) }) + it('renders image controls from the active English dictionary', async () => { + const load = vi.fn().mockResolvedValue('blob:history') + const view = render() + const frame = view.getByRole('button', { name: 'history.png, double-click to view original' }) + await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) + fireEvent.doubleClick(frame) + expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy() + expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy() + }) + it('keeps assistant images at their original position between text blocks', async () => { const view = render( { expect(created).toHaveBeenCalledTimes(11) const beforeRejectedBatch = created.mock.calls.length - expect(() => { - b.root.createDraftImages([ - new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }), - new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }), - ]) - }).toThrow('不支持的图片格式:image/svg+xml') + expect(() => b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }), + new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }), + ])) + .toThrow(UnsupportedImageMediaTypeError) expect(created).toHaveBeenCalledTimes(beforeRejectedBatch) } finally { created.mockRestore() @@ -127,6 +126,33 @@ describe('ConversationService', () => { await b.runtime.dispose() }) + it('restores failed-send images before images added while the request was in flight', async () => { + const b = await bench() + const first = b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }), + ])[0] + const second = b.root.createDraftImages([ + new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' }), + ])[0] + if (first === undefined || second === undefined) throw new Error('draft attachment missing') + const shell = b.hub.shell(b.runtime.sessions.behavior('s1').sessionId) + const request = Promise.withResolvers<{ ok: true; value: { accepted: true } }>() + b.prompt.mockReturnValueOnce(request.promise) + + shell.addImages([first.id]) + shell.setDraft('describe') + shell.submit('queue') + expect(shell.addImages([second.id])).toBe(true) + expect(shell.snapshot.imageIds).toEqual([second.id]) + + request.reject(new Error('transport died')) + await vi.waitFor(() => { + expect(shell.snapshot.imageIds).toEqual([first.id, second.id]) + }) + expect(b.root.draftImages(shell.snapshot.imageIds)).toEqual([first, second]) + await b.runtime.dispose() + }) + it('does not publish a historical image URL after disposal', async () => { let resolveRead!: (result: Awaited>) => void const readAttachment: SessionFace['readAttachment'] = vi.fn(() => new Promise>>( diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index e5100fe954..cac8106935 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: 09f0f53bec81dd559c8da9c94a6b5459019b4ba2 -README.zh.md: bba5efaa0d9c9486b7367a6c8db069d10b18ffb5 +README.md: 5367795aeb7655f9323ab94d71803049ee452cb0 +README.zh.md: c1507f9c25595d83a63326127779c6240f9cc8a7 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 09f0f53bec..5367795aeb 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -20,7 +20,7 @@ Session titles ride the generic projection pair like every other domain — the Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Selection is serialized with image-bearing prompt admission and rejects a text-only target while an image is pending publication or remains in the current derived history; an image removed by compaction no longer blocks selection. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. -Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. +Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. Image-bearing carriers separately gate text-only model selection until publication or discard: idle without publication retires a claimed queued carrier, but steering retained in the agent outbox remains gated across a failed turn. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `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()`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index bba5efaa0d..c1507f9c25 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -20,7 +20,7 @@ 会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。模型选择与包含图片的提示词准入串行执行;当图片正等待发布或仍存在于当前派生历史中时,会拒绝选择纯文本目标;被压缩(compaction)移除的图片不再阻止选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 -待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 +待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。含图片的载体会另行约束纯文本模型选择,直到发布或丢弃:未发布即转入空闲时,已认领的 queued 载体会被退役;但保留在 agent outbox 中的 steering 即使跨越失败轮次也仍受门槛约束。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`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()` 中。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index fb878ffd77..db3872b0f8 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -877,9 +877,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (changed) publishQueue(agent.id) }), ctx.on('agent/status', (agent: Agent, status: AgentStatus) => { - // Idle proves every claimed admission either published (retired by its - // session event) or ended without one; drop the stale gate carriers. - if (status === 'idle') pendingPublication.delete(agent.id) + if (status !== 'idle') return + const pending = pendingPublication.get(agent.id) + if (pending === undefined) return + // A claimed queued prompt disappears when admission ends without + // publication. Steering instead remains staged in the agent outbox + // across a failed turn, so retain it until steering/message or discard. + const kept = pending.filter(item => item.placement === 'steering') + if (kept.length === 0) pendingPublication.delete(agent.id) + else pendingPublication.set(agent.id, kept) }), ctx.on('session/disposed', (session: Session) => { queuedMirror.delete(session.id) diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index c05ef5aea2..c41a4e2a58 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -404,6 +404,11 @@ describe('Web session model selection', () => { ctx.emit('agent/inbox/dequeue', agent, steeringItem) expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + // A failed turn returns idle while leaving steering staged in the outbox; + // only publication or discard may retire this carrier. + ctx.emit('agent/status', agent, 'idle') + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + // Publication hands the gate over to the durable surface. agent.session.append('steering/message', { turn: 1, message: steering }, { surfaceOp: 'append' }) expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) From 76d20727ab37dcd745030a4cd87094c9e3e1c124 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 18:44:18 +0800 Subject: [PATCH 033/229] test(web): pin image snapshot locale --- apps/web/tests/image-display.snapshot.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 5689147170..2093b23214 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -55,6 +55,9 @@ let unmount: (() => void) | undefined beforeEach(() => { localStorage.clear() + // Chinese pinned before boot so the localized role/text locators stay + // deterministic across runner browser languages. + localStorage.setItem('dsh.locale', 'zh') document.title = 'DeepSeek Harness' vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => From b3b0844444bafb644714a6b2054576ade592712c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 18:49:33 +0800 Subject: [PATCH 034/229] test(web): follow composed workspace picker --- apps/web/tests/image-display.snapshot.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 2093b23214..c552817424 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -165,17 +165,12 @@ it('renders the history image pair through the authorized attachment route and o }) it('accepts pasted images into the composer rail in order and removes them', async () => { - boot('?fixture=empty') + boot() await screen.findByPlaceholderText('选择一个工作区开始', {}, { timeout: 10_000 }) fireEvent.click(screen.getAllByRole('button', { name: '选择工作区' }) .find(el => el.getAttribute('aria-haspopup') === 'menu')!) - fireEvent.click(await screen.findByRole('menuitem', { name: '新建工作区' })) - const dialog = await screen.findByRole('dialog', { name: '新建工作区' }) - fireEvent.change(within(dialog).getByRole('textbox', { name: '新工作区名称' }), { - target: { value: 'image-input' }, - }) - fireEvent.click(within(dialog).getByRole('button', { name: '创建工作区' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'fixture' })) // Image-only send arming is pinned at package level (input-bar.spec.tsx); // this assembled lane pins the intake chain over the built graph. From ff7917094444491d7c1738fdfad05fdbcb04daea Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 18:58:03 +0800 Subject: [PATCH 035/229] refactor(web): share user content stack --- .../src/client/chat/MessageItem.tsx | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index c4d69da7ce..135bd43062 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -153,6 +153,29 @@ function projectUserText(text: string): ReactNode { return <>{parts} } +function UserContentStack({ parts, imageLoader, steering = false, t, truncated }: { + parts: ReturnType + imageLoader: ImageLoader + steering?: boolean + t: ChatViewSlotProps['t'] + truncated: (total: number) => string +}) { + const { text, images, rest } = parts + const showBubble = steering || text !== '' || rest.length > 0 + return ( +
+ + {showBubble &&
+ {steering && {t('message.steering')}} + {projectUserText(text)} + {rest.map((block, i) => ( + + ))} +
} +
+ ) +} + export const MessageItem = memo(function MessageItem({ node, loadImage, retryActive = false, onFork, t, }: MessageItemProps) { @@ -160,20 +183,12 @@ export const MessageItem = memo(function MessageItem({ const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { case 'user': { - const { text, images, rest } = contentParts(node.content) + const parts = contentParts(node.content) return (
-
- - {(text !== '' || rest.length > 0) &&
- {projectUserText(text)} - {rest.map((block, i) => ( - - ))} -
} -
+ { onFork(node.seq) }} @@ -184,19 +199,10 @@ export const MessageItem = memo(function MessageItem({ ) } case 'steering': { - const { text, images, rest } = contentParts(node.content) + const parts = contentParts(node.content) return (
-
- -
- {t('message.steering')} - {projectUserText(text)} - {rest.map((block, i) => ( - - ))} -
-
+
) } From cc88018cd40c0bc7a6d7979ed86eb65fdd77682b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 19:30:40 +0800 Subject: [PATCH 036/229] test(web): wait for seeded search inventory --- apps/web/tests/navigation-panes.e2e.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 87e19a6915..60b4596f1f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -98,6 +98,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search')) + // The search input mounts before the asynchronous session inventory. + // Wait for the seeded row so hydration cannot overwrite the query. + await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 }) const search = page.getByPlaceholder('Search name, keywords', { exact: false }) // The cold row has not been opened, so only the persisted log can satisfy // this query. First search lazily reconciles the SQLite content index. From fe2703b641e45e26c863fea1df2395992508d7d2 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:13:43 -0700 Subject: [PATCH 037/229] fix(workspace-context): restore the no-unrepresented-commit gate after master merge The master merge rewrote reconcileInstructionContext and dropped this branch's gate: when no transition survives rendering (tiny budgets produce notice-only text), emit nothing and commit nothing so the next pass retries. Restore it, align the inbox one-byte test with that contract (an unrepresentable change is held back, not committed at 1 byte), and move the nested-retry test's probe assertions to sync time where reconciliation now runs. Also restore master's markdown spec casts lost in an earlier merge. --- .../client/ui-primitives/tests/markdown.spec.tsx | 2 +- packages/context/workspace-context/src/state.ts | 4 ++++ .../tests/workspace-context.spec.ts | 13 ++++++++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 1308403b08..e67302a305 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -162,7 +162,7 @@ describe('MarkdownText', () => { expect(() => tokenizer?.call({ parser: { constructs: { attentionMarkers: {} } }, previous: null, - }, {}, () => undefined, () => undefined)).toThrow( + } as never, {} as never, () => undefined, () => undefined)).toThrow( 'micromark CommonMark attention markers are unavailable', ) }) diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index d0176eef8f..5b35862cd8 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -405,6 +405,10 @@ export async function reconcileInstructionContext( } if (items.length === 0) return undefined const rendered = renderInstructionChanges(items, resolved.maxBytes) + // When no transition survived rendering (tiny budgets render notice-only + // text), emit nothing and commit nothing — the uncommitted versions make the + // next pass retry instead of spamming notice-only contexts. + if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined return { context: workspaceContextHook(rendered.text, rendered.changes), versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes), diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 5ca57fb0cd..0fa1533e4d 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -3815,13 +3815,18 @@ describe('dynamic nested workspace context injection', () => { signal: testToolSignal, callId: CallId('read-tiny-budget-1'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) + await syncWorkspaceContext(ctx, agent) const second = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('read-tiny-budget-2'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) + await syncWorkspaceContext(ctx, agent) expect(first.additionalContexts).toBeUndefined() expect(second.additionalContexts).toBeUndefined() + // Nothing was emitted, and the uncommitted version made the second sync + // probe the instruction file again — the retry. + expect(agent.inbox.nextStep).toHaveLength(0) expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(2) } finally { await ctx.fiber.dispose() @@ -3916,7 +3921,7 @@ describe('workspace context inbox synchronization', () => { } }) - it('keeps a dynamic change within a one-byte positive render budget', async () => { + it('holds back a dynamic change a one-byte positive render budget cannot represent', async () => { const root = await tempRepo() const home = await tempRepo() const ctx = new Context() @@ -3934,8 +3939,10 @@ describe('workspace context inbox synchronization', () => { await syncWorkspaceContext(ctx, agent) - expect(agent.inbox.nextStep).toHaveLength(1) - expect(Buffer.byteLength(blocksText(agent.inbox.nextStep[0]?.content), 'utf8')).toBeLessThanOrEqual(1) + // One byte cannot semantically represent the transition, so nothing is + // emitted and nothing commits — the uncommitted version retries on the + // next touch instead of committing state the model never saw. + expect(agent.inbox.nextStep).toHaveLength(0) } finally { await ctx.fiber.dispose() await rm(root, { recursive: true, force: true }) From dd473870dd388db78079a71edc7756a126ff720d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 20:27:31 +0800 Subject: [PATCH 038/229] fix(web): persist theme preference in settings --- ...host-backed-web-theme-preference.i18n.yaml | 6 + ...-08-06-host-backed-web-theme-preference.md | 39 +++++ ...-06-host-backed-web-theme-preference.zh.md | 39 +++++ apps/web/tests/scaffold.ts | 4 +- apps/web/tests/settings-chrome.e2e.ts | 39 ++++- docs/event-producer-consumer.md | 4 +- docs/module-graph.md | 3 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- packages/client/ui-theme/package.json | 7 +- .../ui-theme/src/client/AppearanceRow.tsx | 2 +- packages/client/ui-theme/src/client/index.ts | 117 ++++++++------ .../ui-theme/src/client/settings-store.ts | 2 +- .../ui-theme/src/client/theme-settings.ts | 100 ++++++++++++ packages/client/ui-theme/src/index.ts | 37 ++++- packages/client/ui-theme/src/invariant.ts | 8 +- .../client/ui-theme/src/theme-settings.ts | 22 +++ packages/client/ui-theme/tests/apply.spec.ts | 66 +++++++- packages/client/ui-theme/tests/host.spec.ts | 30 ++++ .../client/ui-theme/tests/invariant.spec.ts | 15 +- .../ui-theme/tests/theme-settings.spec.ts | 149 ++++++++++++++++++ packages/client/ui-theme/tests/theme.spec.ts | 59 +++---- packages/client/ui-theme/tsconfig.json | 6 + packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 2 +- .../apiproxy/tests/api-proxy-config.spec.ts | 32 +++- pnpm-lock.yaml | 9 ++ 30 files changed, 692 insertions(+), 121 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md create mode 100644 packages/client/ui-theme/src/client/theme-settings.ts create mode 100644 packages/client/ui-theme/src/theme-settings.ts create mode 100644 packages/client/ui-theme/tests/host.spec.ts create mode 100644 packages/client/ui-theme/tests/theme-settings.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml new file mode 100644 index 0000000000..7e804aad59 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.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-06-host-backed-web-theme-preference.md +2026-08-06-host-backed-web-theme-preference.md: 129132586b0d0ccfdb5b32fdaa1f7178a7176db7 +2026-08-06-host-backed-web-theme-preference.zh.md: 0c2dafff3fc3cec49a2261e31a61ecf99a10f126 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md new file mode 100644 index 0000000000..129132586b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md @@ -0,0 +1,39 @@ +# Agent Note: Persist the Web theme through Host settings + +Status: implemented + +English | [中文](2026-08-06-host-backed-web-theme-preference.zh.md) + +## Problem + +The Web theme preference lived in browser `localStorage`. Browser storage is scoped to an origin, so reopening `dsh web` on another port selected a different storage partition and returned to the default system theme even though both processes used the same DSH home. + +The theme is a user-level product preference rather than page-local state. DSH already has a user-settings service with a file-backed provider, a loopback-only configuration wire, and invalidation frames for external edits and other tabs. + +## Decision + +The `@deepseek-ai/dsh-client-ui-theme` Host half registers `ui-theme.preference` with the built-in `light`, `dark`, and `system` values and a `system` default. The local settings provider stores an override in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. + +The loopback client loads that namespace before it provides `ThemeService`, so the initial presenter snapshot reflects the durable preference without relying on an origin cache. `ThemeService.setTheme` still changes the live snapshot synchronously; its persistence callback sends a `settings.mutate` path operation. The controller serializes rapid selections in gesture order, ignores stale settlements, reloads after a rejected latest write, and refetches on `settings/changed` or `connection/reset`. + +The API proxy explicitly exposes `ui-theme` beside `permission` and `ui-onboarding`. Registration alone remains insufficient to cross the configuration boundary. Remote browsers cannot call the privileged settings API and retain only a process-local selection. + +Only the built-in product preferences cross the Host schema. Third-party registered theme ids remain an in-process extension because the Host cannot validate a browser plugin's dynamic registry during startup. + +## Alternatives considered + +**Keep `localStorage` and copy values between ports.** One origin cannot enumerate another origin's storage, and a Host-side relay would recreate a settings service around a browser-specific format. + +**Use a cookie without an explicit port.** Cookies would couple preference durability to the served hostname, still split localhost aliases, and introduce HTTP state outside the user-settings ownership model. + +**Mirror Host settings into `localStorage`.** A second authority creates boot and invalidation conflict rules while retaining the origin partition that caused the defect. The Host document is the sole durable source. + +**Expose every registered settings namespace.** Automatic exposure would let an unrelated plugin become remotely configurable by registering with the general settings seam. The API proxy keeps an explicit allowlist. + +## Consequences + +Theme selections follow the DSH user home across reloads, ports, and loopback origins, and direct edits to `settings.yaml` converge through the existing invalidation stream. The settings document contains a readable section such as `ui-theme: { preference: dark }`; no theme value is written to `localStorage`. + +Startup performs one loopback settings read before publishing the theme service. A transient read failure keeps the system default or last good in-process value and reconnect can retry. A write rejection can visibly restore the durable preference after the immediate theme change. + +Unit coverage pins schema registration, ordered writes, stale-response containment, failure recovery, invalidation refresh, and remote memory mode. The real Web settings scenario writes dark through the UI, verifies the YAML document, reloads, and boots a second Host on another port against the same DSH home with an empty theme `localStorage` partition. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md new file mode 100644 index 0000000000..0c2dafff3f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 通过 Host settings 持久化 Web 主题 + +Status: implemented + +[English](2026-08-06-host-backed-web-theme-preference.md) | 中文 + +## 问题 + +Web 主题偏好原本存在浏览器 `localStorage` 中。浏览器存储以 origin 为作用域,因此换一个端口重新打开 `dsh web` 会选中另一个存储分区,并回到默认的系统主题,即使两个进程使用同一个 DSH home。 + +主题是用户级产品偏好,而非页面局部状态。DSH 已有用户 settings 服务及其基于文件的提供方,也已有仅限回环请求的配置协议,并为外部编辑和其他标签页提供失效帧。 + +## 决策 + +`@deepseek-ai/dsh-client-ui-theme` 的 Host half 注册 `ui-theme.preference`,可取内置值 `light`、`dark` 与 `system`,默认值为 `system`。本地 settings 提供方将覆盖值存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。 + +来自回环地址的客户端会在提供 `ThemeService` 之前加载该 namespace,因此初始呈现器快照会反映持久化偏好,无需依赖按 origin 划分的缓存。`ThemeService.setTheme` 仍会同步更新实时快照;它的持久化回调会发送一项 `settings.mutate` 路径操作。控制器按操作顺序串行处理连续快速选择,忽略陈旧操作的结算结果,在最新写入被拒后重新加载持久化值,并在发生 `settings/changed` 或 `connection/reset` 时重新拉取。 + +API 代理会显式暴露 `ui-theme`,与 `permission` 和 `ui-onboarding` 并列。仅注册该设置,仍不足以跨越配置边界。远程浏览器无法调用特权 settings API,其主题选择仅保留在进程内。 + +只有产品内置偏好才会跨越 Host schema。第三方注册的主题 id 仍是进程内扩展,因为 Host 无法在启动期间校验浏览器插件的动态注册表。 + +## 曾考虑的替代方案 + +**保留 `localStorage`,并在不同端口间复制值。** 一个 origin 无法枚举另一个 origin 的存储,而 Host 侧中继会围绕浏览器特有格式重新实现一套 settings 服务。 + +**使用不显式包含端口的 cookie。** Cookie 会将偏好的持久性与提供服务的 hostname 耦合,localhost 的不同 alias 仍会各自分区,还会在用户 settings 的所有权模型之外引入 HTTP 状态。 + +**将 Host settings 镜像到 `localStorage`。** 第二个权威来源会导致启动与失效时需要另外定义冲突规则,同时依然保留造成该缺陷的 origin 分区。Host 侧 settings 文档是唯一的持久化真源。 + +**暴露所有已注册的 settings namespace。** 自动暴露会让与本功能无关的插件仅凭向通用 settings seam 注册,就成为可远程配置的插件。API 代理保留一份显式 allowlist。 + +## 后果 + +主题选择会跟随 DSH 用户 home,跨越重新加载、端口与回环 origin;直接编辑 `settings.yaml` 所产生的变更也会通过现有失效流收敛。settings 文档包含形如 `ui-theme: { preference: dark }` 的可读分节;不会向 `localStorage` 写入主题值。 + +启动时会在发布主题服务之前执行一次回环 settings 读取。短暂的读取失败会保留系统默认值或上一个正确的进程内值,并可在重连时重试。写入被拒时,界面可能会在主题立即变化后明显恢复为持久化偏好。 + +单元测试覆盖 schema 注册、有序写入、陈旧响应隔离、故障恢复、失效刷新与远程端仅内存模式。真实 Web settings 场景通过 UI 写入 dark,校验 YAML 文档,重新加载,再使用同一个 DSH home 在另一个端口上启动第二个 Host,此时主题 `localStorage` 分区为空。 diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index dc68cbf67d..bec4afa86e 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -185,6 +185,8 @@ export interface LaunchOptions { * 127.0.0.1; a non-resolving authority fails before Host trust is exercised. */ remoteAuthority?: string + /** Reuse an existing harness home so a second Host can verify user settings across origins. */ + harnessHome?: string } /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */ @@ -231,7 +233,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise localStorage dsh.theme +// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> Host settings // -> theme/change -> ui-layout's presenter -> body attribute -> alias token) // the Language row (settings-scoped localization + persisted dsh.locale), // the busy-state Enter preference, plus Permission as the persisted default @@ -152,13 +152,13 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('flips the theme through the Appearance cubes and persists across reload', async () => { + it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance')) - const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> => - await page.evaluate(() => ({ + const readState = async (target: Page = page): Promise<{ attr: boolean; token: string; legacy: string | null }> => + await target.evaluate(() => ({ attr: document.body.hasAttribute('data-ds-dark-theme'), token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(), - stored: localStorage.getItem('dsh.theme'), + legacy: localStorage.getItem('dsh.theme'), })) // Pin the OS scheme to light so the default `system` preference resolves // light and the dark flip below is unambiguously the gesture's doing. @@ -172,13 +172,15 @@ describe('web e2e: settings modal and General preferences', () => { const darkCube = dialog.getByRole('button', { name: '深色' }) expect(await darkCube.getAttribute('aria-pressed')).toBe('false') await darkCube.click() - // The full cascade: pressed state, persisted preference, body attribute, + // The full cascade: pressed state, Host-backed preference, body attribute, // alias token flip — all from one real user gesture. await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') const dark = await readState() expect(dark.attr).toBe(true) - expect(dark.stored).toBe('dark') + expect(dark.legacy).toBeNull() expect(dark.token).not.toBe(light.token) + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-theme:\n\s+preference: dark/) await page.keyboard.press('Escape') // Reload: the preference survives boot (restore + presenter initial apply). @@ -189,7 +191,28 @@ describe('web e2e: settings modal and General preferences', () => { await page.emulateMedia({ colorScheme: 'light' }) const reloaded = await readState() expect(reloaded.attr).toBe(true) - expect(reloaded.stored).toBe('dark') + expect(reloaded.legacy).toBeNull() + + // A second live Host binds another ephemeral port but shares the same + // user-settings home. Its fresh origin has no theme localStorage and must + // still render dark before the settings dialog opens. + const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome }) + const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const secondTripwire = watchConsole(secondPage) + try { + expect(second.baseUrl).not.toBe(scaffold.baseUrl) + await secondPage.emulateMedia({ colorScheme: 'light' }) + await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) + await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const crossPort = await readState(secondPage) + expect(crossPort.attr).toBe(true) + expect(crossPort.legacy).toBeNull() + expect(secondTripwire.pageErrors).toEqual([]) + expect(secondTripwire.warnings).toEqual([]) + } finally { + await secondPage.close() + await second.close() + } // `system` follows the emulated OS scheme (dark stays dark, light clears). await page.getByRole('button', { name: '设置', exact: true }).click() diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 23794b5c9d..59b20a6762 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -62,14 +62,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `commands/changed` | `runtime` (`emit`) | `ui-command` | -| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | +| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general`, `ui-theme` | | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | -| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` | +| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general`, `ui-theme` | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | | `slash/input-insert-reference` | - | `ui-conversation` | diff --git a/docs/module-graph.md b/docs/module-graph.md index 3ad8ef0b7e..882f30dd07 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -399,6 +399,7 @@ flowchart TD pkg_client_ui_slash --> pkg_client_ui_primitives pkg_client_ui_slash --> pkg_client_ui_slots pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_client_connection pkg_client_ui_theme --> pkg_client_locale pkg_client_ui_theme --> pkg_client_runtime pkg_client_ui_theme --> pkg_client_ui_primitives @@ -1150,7 +1151,7 @@ flowchart TD | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`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-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`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-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`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-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`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-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 76bcbaf608..04fd1e81c2 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/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-theme/README.md -README.md: 88e21fe214ec806b101050949690283d811be36d -README.zh.md: ba781ba89a62292928a7b05ab94ea1cd930b4f50 +README.md: 32868bcac4313a3badfe92dbf41c84e793f09709 +README.zh.md: a38765b8004826133875c38deeb66128d52ec986 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 88e21fe214..32868bcac4 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). Contract: api-contracts v3 §8. +Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser loads `ui-theme.preference` before providing the service and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema. Contract: api-contracts v3 §8; the [Host-backed preference decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md) owns the persistence boundary. `src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index ba781ba89a..a38765b800 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。契约:api-contracts v3 §8。 +主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会在提供该服务前加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema。契约:api-contracts v3 §8;该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md)拥有。 `src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。 diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 1adad710cc..7635da17b8 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -25,6 +25,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale" ], @@ -33,6 +34,7 @@ }, "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", @@ -42,6 +44,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", @@ -64,6 +67,8 @@ "watch": "tsdown --watch" }, "dependencies": { - "clsx": "^2.0.0" + "@deepseek-ai/dsh-settings": "workspace:^", + "clsx": "^2.0.0", + "schemastery": "^3.18.0" } } diff --git a/packages/client/ui-theme/src/client/AppearanceRow.tsx b/packages/client/ui-theme/src/client/AppearanceRow.tsx index a0e04b67a6..e482f5ed2e 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.tsx +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -10,7 +10,7 @@ import { IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import type { ThemePreference } from './index.ts' +import type { ThemePreference } from '../theme-settings.ts' import type { ThemeKey } from './locales.ts' import type {} from './settings-contract.ts' import type { createAppearanceRowStore } from './settings-store.ts' diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index eb096412f5..497f4a22f1 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -1,12 +1,14 @@ /** * Browser theme registry over the `--dsw-*` token stylesheets. The service - * owns the theme preference (light/dark/system), resolves `system` through + * owns the live theme preference (light/dark/system), resolves `system` through * `prefers-color-scheme`, and publishes immutable snapshots; it never touches - * the DOM — ui-layout's presenter consumes the resolved snapshot. The plugin - * also registers the Appearance preference row into the settings General - * section — the theme feature owns its own settings surface. + * the DOM — ui-layout's presenter consumes the resolved snapshot. The Host + * settings controller loads and stores the preference in the user-settings + * document. The plugin also registers the Appearance preference row into the + * settings General section — the theme feature owns its own settings surface. */ import type { Context } from 'cordis' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -14,11 +16,22 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' import { AppearanceRow } from './AppearanceRow.tsx' import { createAppearanceRowStore } from './settings-store.ts' +import { ThemeSettingsController } from './theme-settings.ts' import { en, zh, type ThemeKey } from './locales.ts' +import { + DEFAULT_PREFERENCE, isThemePreference, THEME_SETTINGS_NAMESPACE, + type ThemePreference, +} from '../theme-settings.ts' export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' export type { AppearanceRowState } from './settings-store.ts' +export type { ThemePreferenceTarget } from './theme-settings.ts' +export { ThemeSettingsController } from './theme-settings.ts' export type { ThemeKey } from './locales.ts' +export { + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + type ThemePreference, +} from '../theme-settings.ts' /** Namespace owning this feature's settings-row copy. */ export const SETTINGS_NS = 'settings.theme' @@ -33,9 +46,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */ export type ThemeTokens = Record -/** Theme preference: a concrete theme id or follow-the-OS. */ -export type ThemePreference = 'light' | 'dark' | 'system' - /** One selectable theme: id, dark/light semantics, and alias-token overrides. */ export interface ThemeDefinition { /** Theme id (the setTheme argument for concrete themes). */ @@ -76,12 +86,6 @@ declare module 'cordis' { } } -/** localStorage key holding the persisted theme preference. */ -export const STORAGE_KEY = 'dsh.theme' - -/** Default preference when nothing (or garbage) is persisted. */ -export const DEFAULT_PREFERENCE: ThemePreference = 'system' - const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([ Object.freeze({ id: 'light', colorScheme: 'light' as const, tokens: Object.freeze({}) }), Object.freeze({ id: 'dark', colorScheme: 'dark' as const, tokens: Object.freeze({}) }), @@ -103,14 +107,17 @@ export class ThemeService { private revision = 0 private snapshot: ThemeSnapshot private readonly media: MediaQueryList | undefined + private persist: (preference: ThemePreference) => void /** * @param ctx - owning context (change events are emitted on it; the * media-query listener is released through ctx.effect on dispose). + * @param persist - durable write callback for built-in preferences. */ - constructor(ctx: Context) { + constructor(ctx: Context, persist: (preference: ThemePreference) => void = () => {}) { this.ctx = ctx - this.preference = restorePreference() + this.persist = persist + this.preference = DEFAULT_PREFERENCE // Non-browser runs (node e2e booting the client tree) have no matchMedia. this.media = typeof matchMedia === 'undefined' ? undefined : matchMedia('(prefers-color-scheme: dark)') this.snapshot = this.buildSnapshot() @@ -136,8 +143,17 @@ export class ThemeService { } /** - * Switch the theme preference — the only preference write entry. Persists - * the preference and emits `theme/change`. + * Bind the owning plugin's durable writer before the service is provided. + * @param persist - callback accepting built-in preference changes. + */ + bindPersistence(persist: (preference: ThemePreference) => void): void { + this.persist = persist + } + + /** + * Switch the theme preference — the only user preference write entry. + * Built-in preferences are persisted and every accepted value emits + * `theme/change`. * @param id - a registered theme id or `system`; unknown ids throw. */ setTheme(id: string): void { @@ -146,7 +162,17 @@ export class ThemeService { } if (this.preference === id) return this.preference = id as ThemePreference - persistPreference(this.preference) + if (isThemePreference(id)) this.persist(id) + this.publish() + } + + /** + * Apply a preference read from Host settings without writing it back. + * @param preference - validated durable preference. + */ + syncPreference(preference: ThemePreference): void { + if (this.preference === preference) return + this.preference = preference this.publish() } @@ -170,7 +196,7 @@ export class ThemeService { this.themes = this.themes.filter(t => t.id !== definition.id) if (this.preference === definition.id) { this.preference = DEFAULT_PREFERENCE - persistPreference(this.preference) + this.persist(this.preference) } this.publish() } @@ -200,32 +226,8 @@ export class ThemeService { } } -/** Read the persisted preference; unknown or unreadable values fall back to the default. */ -function restorePreference(): ThemePreference { - // Non-browser runs (node e2e booting the client tree) have no localStorage. - if (typeof localStorage === 'undefined') return DEFAULT_PREFERENCE - try { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored === 'light' || stored === 'dark' || stored === 'system') return stored - } catch { - // Storage access can throw (privacy mode); the default below covers it. - } - return DEFAULT_PREFERENCE -} - -/** Persist the preference; storage failures are non-fatal (preference resets next boot). */ -function persistPreference(preference: ThemePreference): void { - if (typeof localStorage === 'undefined') return - try { - localStorage.setItem(STORAGE_KEY, preference) - } catch { - // Storage access can throw (privacy mode / quota); the preference simply - // does not survive the session. - } -} - -/** Required services: slots + locale (the feature registers its own settings row with localized copy). */ -export const inject = ['slots', 'locale'] +/** Required services: settings transport plus slots/locale for the Appearance row. */ +export const inject = ['slots', 'locale', 'connection'] /** * Client plugin body: provide the theme service and register the @@ -233,10 +235,33 @@ export const inject = ['slots', 'locale'] * slot (a feature owns its settings surface). * @param ctx - client cordis context. */ -export function apply(ctx: ClientContext): void { +export async function apply(ctx: ClientContext): Promise { + const connection = ctx.get('connection') as ConnectionHandle const theme = new ThemeService(ctx) + const controller = new ThemeSettingsController( + connection.api, + theme, + connection.isLoopback ? 'host' : 'memory', + ) + theme.bindPersistence((preference) => { void controller.persist(preference) }) + await controller.load() ctx.provide('theme', theme) + ctx.effect(() => { + const refresh = (ns?: string): void => { + if (ns !== undefined && ns !== THEME_SETTINGS_NAMESPACE) return + void controller.load() + } + const disposers = [ + ctx.on('settings/changed', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + return () => { + controller.dispose() + for (const dispose of disposers) dispose() + } + }, 'ui-theme: settings invalidations') + ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') const store = createAppearanceRowStore() diff --git a/packages/client/ui-theme/src/client/settings-store.ts b/packages/client/ui-theme/src/client/settings-store.ts index 256b04a299..e4c76154e5 100644 --- a/packages/client/ui-theme/src/client/settings-store.ts +++ b/packages/client/ui-theme/src/client/settings-store.ts @@ -4,7 +4,7 @@ * reads via props.useStore. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' -import type { ThemePreference } from './index.ts' +import type { ThemePreference } from '../theme-settings.ts' /** Store state mirrored from the theme snapshot. */ export interface AppearanceRowState { diff --git a/packages/client/ui-theme/src/client/theme-settings.ts b/packages/client/ui-theme/src/client/theme-settings.ts new file mode 100644 index 0000000000..66b332313b --- /dev/null +++ b/packages/client/ui-theme/src/client/theme-settings.ts @@ -0,0 +1,100 @@ +/** Host-backed persistence controller for the browser theme preference. */ + +import type { + IApiClient, SettingsNamespaceView, +} from '@deepseek-ai/dsh-client-connection/client' +import { + THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, isThemePreference, + type ThemePreference, +} from '../theme-settings.ts' + +/** Preference target implemented by {@link ThemeService}. */ +export interface ThemePreferenceTarget { + /** + * Apply a Host value without writing it back. + * @param preference - validated durable preference. + */ + syncPreference(preference: ThemePreference): void +} + +function preferenceOf(view: SettingsNamespaceView): ThemePreference | undefined { + if (typeof view.value !== 'object' || view.value === null) return undefined + const preference = (view.value as Record)[THEME_PREFERENCE_FIELD] + return isThemePreference(preference) ? preference : undefined +} + +/** Coordinates startup reads, ordered writes, and pushed invalidations. */ +export class ThemeSettingsController { + private generation = 0 + private writeTail: Promise = Promise.resolve() + + /** + * @param api - settings wire face. + * @param target - live theme service receiving durable values. + * @param persistence - remote browsers stay process-local because the settings API is loopback-only. + */ + constructor( + private readonly api: Pick, + private readonly target: ThemePreferenceTarget, + private readonly persistence: 'host' | 'memory' = 'host', + ) {} + + /** + * Load the durable preference after earlier writes settle; the latest operation wins. + * @returns nothing; an unavailable or invalid descriptor leaves the last good value active. + */ + async load(): Promise { + const generation = ++this.generation + if (this.persistence === 'memory') return + await this.writeTail + if (generation !== this.generation) return + let response: Awaited['settings']['describe']>> + try { + response = await this.api.settings.describe({}) + } catch (_settingsReadFailure) { + // A transport failure leaves the last good in-process theme active. A + // connection/reset or settings/changed notification retries the read. + return + } + if (!response.result.ok || generation !== this.generation) return + const view = response.result.value.namespaces.find( + candidate => candidate.ns === THEME_SETTINGS_NAMESPACE, + ) + if (view === undefined) return + const preference = preferenceOf(view) + if (preference !== undefined) this.target.syncPreference(preference) + } + + /** + * Persist one user selection. Writes are serialized so rapid picks land in + * gesture order; a rejected latest write reloads the durable value. + * @param preference - selected built-in preference. + * @returns nothing after the write or recovery read settles. + */ + async persist(preference: ThemePreference): Promise { + const generation = ++this.generation + if (this.persistence === 'memory') return + const write = this.writeTail.then(async () => { + const response = await this.api.settings.mutate({ + ns: THEME_SETTINGS_NAMESPACE, + ops: [{ op: 'set', path: [THEME_PREFERENCE_FIELD], value: preference }], + }) + if (!response.result.ok) throw new Error(response.result.error.message) + if (generation === this.generation) { + const accepted = preferenceOf(response.result.value) + if (accepted !== undefined) this.target.syncPreference(accepted) + } + }) + this.writeTail = write.catch(() => {}) + try { + await write + } catch { + if (generation === this.generation) await this.load() + } + } + + /** Prevent in-flight reads and writes from publishing after plugin disposal. */ + dispose(): void { + this.generation += 1 + } +} diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index 4777b0eb43..5f746d6d83 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -1,4 +1,35 @@ -/** Host loader entry for the browser implementation exported from `./client`. */ +/** Host registration for the browser theme preference. */ -/** Host plugin body — no host-side behavior for the theme plugin. */ -export function apply(): void {} +import type { Context } from 'cordis' +import z from 'schemastery' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + type ThemePreference, +} from './theme-settings.ts' + +export { + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + type ThemePreference, +} from './theme-settings.ts' + +interface ThemeSettings { + preference: ThemePreference +} + +const ThemeSettingsSchema: z = z.object({ + [THEME_PREFERENCE_FIELD]: z.union(['light', 'dark', 'system']).default(DEFAULT_PREFERENCE), +}) + +/** + * Register the durable theme section when a settings provider exists. + * @param ctx - Host context whose optional settings service owns the section. + */ +export function apply(ctx: Context): void { + ctx.inject(['settings'], (settingsCtx) => { + settingsCtx.settings.register( + settingsNamespace(THEME_SETTINGS_NAMESPACE), + ThemeSettingsSchema, + ) + }) +} diff --git a/packages/client/ui-theme/src/invariant.ts b/packages/client/ui-theme/src/invariant.ts index 4ec3296cd6..e15985a9dc 100644 --- a/packages/client/ui-theme/src/invariant.ts +++ b/packages/client/ui-theme/src/invariant.ts @@ -15,10 +15,10 @@ export const name = 'client-ui-theme-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the theme registry publishes immutable snapshots on - * its own `theme/change` event synchronously with the setter/registry - * mutation in the same service — snapshot/event agreement is asserted - * directly by this package's behavior specs. + * No runtime invariant: the settings seam validates and publishes the durable + * theme section, while the registry emits `theme/change` synchronously with + * its own mutations. Store/registry agreement is covered directly by this + * package's Host, controller, and service behavior specs. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-theme/src/theme-settings.ts b/packages/client/ui-theme/src/theme-settings.ts new file mode 100644 index 0000000000..e93b3c56e0 --- /dev/null +++ b/packages/client/ui-theme/src/theme-settings.ts @@ -0,0 +1,22 @@ +/** Theme preferences stored in the Host user-settings document. */ + +/** Settings namespace owned by the theme plugin. */ +export const THEME_SETTINGS_NAMESPACE = 'ui-theme' + +/** Field carrying the selected built-in theme preference. */ +export const THEME_PREFERENCE_FIELD = 'preference' + +/** Theme preference persisted by the product Appearance row. */ +export type ThemePreference = 'light' | 'dark' | 'system' + +/** Default preference when the user-settings document has no override. */ +export const DEFAULT_PREFERENCE: ThemePreference = 'system' + +/** + * Narrow one wire or registry value to a persistable preference. + * @param value - value crossing the settings or registry boundary. + * @returns whether the value is a built-in preference. + */ +export function isThemePreference(value: unknown): value is ThemePreference { + return value === 'light' || value === 'dark' || value === 'system' +} diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index a4da553516..350ea0525a 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -2,11 +2,13 @@ * locale service, declaration-aware Appearance row registration, snapshot * projection into the row store, and HMR collapse recovery. */ import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' -import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' +import { + apply, inject, SETTINGS_NS, THEME_SETTINGS_NAMESPACE, +} from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore } from '../src/client/settings-store.ts' @@ -17,12 +19,39 @@ usePinnedBrowserLanguages('zh-CN') const SLOT = 'settings.general.item' -async function bench() { +async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotsService).await() const locale = new LocaleService(ctx) ctx.provide('locale', locale) - return { ctx, slots: ctx.get('slots') as SlotsService, locale } + let preference = 'system' + const namespace = () => ({ + ns: THEME_SETTINGS_NAMESPACE, + schema: {}, + value: { preference }, + applies: 'live' as const, + secrets: [], + revision: 0, + }) + const describe = vi.fn(() => Promise.resolve({ + rpcId: 'theme-describe' as never, + result: { + ok: true as const, + value: { writable: true, hasDocument: true, namespaces: [namespace()] }, + }, + })) + const mutate = vi.fn((request: { ops: { value: string }[] }) => { + preference = request.ops[0]!.value + return Promise.resolve({ + rpcId: 'theme-mutate' as never, + result: { ok: true as const, value: namespace() }, + }) + }) + ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never) + return { + ctx, slots: ctx.get('slots') as SlotsService, locale, describe, mutate, + setHostPreference: (next: string) => { preference = next }, + } } /** Stand in for the settings shell: declare the General item slot from root. */ @@ -45,7 +74,7 @@ function faceOf(slots: SlotsService) { describe('ui-theme apply', () => { it('declares the slot and locale services', () => { - expect(inject).toEqual(['slots', 'locale']) + expect(inject).toEqual(['slots', 'locale', 'connection']) }) it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => { @@ -84,6 +113,33 @@ describe('ui-theme apply', () => { face.setTheme('system') expect(theme.getTheme().preference).toBe('system') expect(instance.getSnapshot().preference).toBe('system') + await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) }) + }) + + it('loads Host settings at boot, refreshes its namespace, and keeps remote browsers process-local', async () => { + const b = await bench() + b.setHostPreference('dark') + declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const theme = b.ctx.get('theme') as ThemeService + expect(theme.getTheme().preference).toBe('dark') + b.ctx.emit('settings/changed', 'unrelated') + expect(b.describe).toHaveBeenCalledOnce() + b.setHostPreference('light') + b.ctx.emit('settings/changed', THEME_SETTINGS_NAMESPACE) + await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') }) + b.setHostPreference('dark') + b.ctx.emit('connection/reset') + await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) + + const remote = await bench(false) + declareItems(remote.slots) + await remote.ctx.plugin({ inject: [...inject], apply }).await() + const remoteTheme = remote.ctx.get('theme') as ThemeService + remoteTheme.setTheme('dark') + await Promise.resolve() + expect(remote.describe).not.toHaveBeenCalled() + expect(remote.mutate).not.toHaveBeenCalled() }) it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { diff --git a/packages/client/ui-theme/tests/host.spec.ts b/packages/client/ui-theme/tests/host.spec.ts new file mode 100644 index 0000000000..6cbbd91c27 --- /dev/null +++ b/packages/client/ui-theme/tests/host.spec.ts @@ -0,0 +1,30 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { + DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply, +} from '@deepseek-ai/dsh-client-ui-theme' + +class MemorySettings extends Settings { + readonly writable = true + protected load(): Promise> { return Promise.resolve({}) } + protected persist(_ns: SettingsNamespace, _section: Record): Promise { + return Promise.resolve() + } +} + +describe('ui-theme host', () => { + it('registers, validates, and disposes the durable theme namespace with its fiber', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings).await() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const ns = settingsNamespace(THEME_SETTINGS_NAMESPACE) + expect(ctx.settings.get(ns)).toEqual({ preference: DEFAULT_PREFERENCE }) + await ctx.settings.update(ns, { preference: 'dark' }) + expect(ctx.settings.get(ns)).toEqual({ preference: 'dark' }) + await expect(ctx.settings.update(ns, { preference: 'sepia' })).rejects.toThrow() + await fiber.dispose() + expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns) + }) +}) diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.spec.ts index 640599ea43..42a2651099 100644 --- a/packages/client/ui-theme/tests/invariant.spec.ts +++ b/packages/client/ui-theme/tests/invariant.spec.ts @@ -15,18 +15,25 @@ describe('invariant companion', () => { await expect(ctx.plugin(ThemeInvariant).await()).resolves.toBeDefined() }) - it('node-half apply is a no-op host placeholder', () => { - nodeApply() - expect(true).toBe(true) // reaching here without throw is the contract + it('node-half waits for an optional settings provider', () => { + nodeApply(new Context()) + expect(true).toBe(true) }) it('client apply provides ctx.theme over the slots/locale edges', async () => { // The feature registers its own Appearance settings row with localized // copy, hence the slots + locale edges. - expect(inject).toEqual(['slots', 'locale']) + expect(inject).toEqual(['slots', 'locale', 'connection']) const ctx = new Context() new SlotsService(ctx) await ctx.plugin({ inject: ['slots'], apply: localeApply }).await() + ctx.provide('connection', { + api: { settings: { describe: () => Promise.resolve({ + rpcId: 'theme-invariant' as never, + result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } }, + }) } }, + isLoopback: true, + } as never) await ctx.plugin({ inject, apply: clientApply }).await() expect(ctx.get('theme')).toBeInstanceOf(ThemeService) }) diff --git a/packages/client/ui-theme/tests/theme-settings.spec.ts b/packages/client/ui-theme/tests/theme-settings.spec.ts new file mode 100644 index 0000000000..b2b921a4c2 --- /dev/null +++ b/packages/client/ui-theme/tests/theme-settings.spec.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { + THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, ThemeSettingsController, + type ThemePreference, +} from '@deepseek-ai/dsh-client-ui-theme/client' + +let rpc = 0 + +function ok(value: T): RpcResponse { + return { rpcId: `theme-${rpc++}` as never, result: { ok: true, value } } +} + +function view(preference: unknown = 'system'): SettingsNamespaceView { + return { + ns: THEME_SETTINGS_NAMESPACE, + schema: {}, + value: { [THEME_PREFERENCE_FIELD]: preference }, + applies: 'live', + secrets: [], + revision: 0, + } +} + +function described(preference: unknown = 'system') { + return ok({ writable: true, hasDocument: true, namespaces: [view(preference)] }) +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +function target() { + const values: ThemePreference[] = [] + return { values, syncPreference: (preference: ThemePreference) => { values.push(preference) } } +} + +describe('ThemeSettingsController', () => { + it('loads a valid Host value and ignores unavailable or malformed namespaces', async () => { + const receiver = target() + const describe = vi.fn() + .mockResolvedValueOnce(described('dark')) + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) + .mockResolvedValueOnce(described('sepia')) + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [{ ...view(), value: null }] })) + .mockResolvedValueOnce({ + rpcId: 'failed' as never, + result: { ok: false as const, error: { code: 'internal' as const, message: 'offline', details: {} } }, + }) + .mockRejectedValueOnce(new Error('transport offline')) + const controller = new ThemeSettingsController({ settings: { describe } } as never, receiver) + for (let i = 0; i < 6; i++) await controller.load() + expect(receiver.values).toEqual(['dark']) + }) + + it('persists ordered rapid selections and publishes only the latest settlement', async () => { + const first = deferred>>() + const calls: string[] = [] + const mutate = vi.fn(async (request: { ops: { value: string }[] }) => { + const preference = request.ops[0]!.value + calls.push(preference) + if (preference === 'dark') return first.promise + return ok(view(preference)) + }) + const receiver = target() + const controller = new ThemeSettingsController({ settings: { mutate } } as never, receiver) + const dark = controller.persist('dark') + const light = controller.persist('light') + await Promise.resolve() + expect(calls).toEqual(['dark']) + first.resolve(ok(view('dark'))) + await Promise.all([dark, light]) + expect(calls).toEqual(['dark', 'light']) + expect(receiver.values).toEqual(['light']) + expect(mutate).toHaveBeenNthCalledWith(1, { + ns: THEME_SETTINGS_NAMESPACE, + ops: [{ op: 'set', path: [THEME_PREFERENCE_FIELD], value: 'dark' }], + }) + }) + + it('reloads after a rejected latest write and contains stale reads and disposal', async () => { + const stale = deferred>() + const describe = vi.fn() + .mockImplementationOnce(() => stale.promise) + .mockResolvedValueOnce(described('system')) + const mutate = vi.fn().mockResolvedValue({ + rpcId: 'rejected' as never, + result: { ok: false as const, error: { code: 'settings-rejected' as const, message: 'disk full', details: {} } }, + }) + const receiver = target() + const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver) + const oldLoad = controller.load() + await vi.waitFor(() => { expect(describe).toHaveBeenCalledOnce() }) + await controller.persist('dark') + stale.resolve(described('light')) + await oldLoad + expect(receiver.values).toEqual(['system']) + + const disposedRead = deferred>() + describe.mockImplementationOnce(() => disposedRead.promise) + const pending = controller.load() + controller.dispose() + disposedRead.resolve(described('dark')) + await pending + expect(receiver.values).toEqual(['system']) + }) + + it('keeps remote-browser persistence in memory without calling Host settings', async () => { + const describe = vi.fn() + const mutate = vi.fn() + const receiver = target() + const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver, 'memory') + await controller.load() + await controller.persist('dark') + expect(describe).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() + expect(receiver.values).toEqual([]) + }) + + it('reloads after a thrown write and ignores a malformed success response', async () => { + const receiver = target() + const describe = vi.fn().mockResolvedValue(described('light')) + const mutate = vi.fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(ok(view('sepia'))) + const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver) + await controller.persist('dark') + await controller.persist('system') + expect(receiver.values).toEqual(['light']) + }) + + it('lets an explicit refresh supersede a stale rejected write', async () => { + const rejected = deferred() + const receiver = target() + const describe = vi.fn().mockResolvedValue(described('system')) + const mutate = vi.fn().mockReturnValue(rejected.promise) + const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver) + const write = controller.persist('dark') + await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + const refresh = controller.load() + rejected.reject(new Error('stale rejection')) + await Promise.all([write, refresh]) + expect(receiver.values).toEqual(['system']) + expect(describe).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index c853c9fd67..68f0f3c7f8 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -1,21 +1,22 @@ // @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' -import { STORAGE_KEY, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' +import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' -const make = (): { ctx: Context; theme: ThemeService; events: ThemeSnapshot[] } => { +const make = (persist = vi.fn()): { + ctx: Context + theme: ThemeService + events: ThemeSnapshot[] + persist: typeof persist +} => { const ctx = new Context() const events: ThemeSnapshot[] = [] ctx.on('theme/change', (snapshot) => { events.push(snapshot) }) - return { ctx, theme: new ThemeService(ctx), events } + return { ctx, theme: new ThemeService(ctx, persist), events, persist } } describe('ThemeService', () => { - beforeEach(() => { - localStorage.clear() - }) - it('defaults to the system preference resolved against prefers-color-scheme', () => { const { theme } = make() const snapshot = theme.getTheme() @@ -26,12 +27,12 @@ describe('ThemeService', () => { expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark']) }) - it('setTheme switches, persists, republishes, and keeps DOM untouched', () => { - const { theme, events } = make() + it('setTheme switches, requests persistence, republishes, and keeps DOM untouched', () => { + const { theme, events, persist } = make() theme.setTheme('dark') expect(theme.getTheme().preference).toBe('dark') expect(theme.getTheme().active.colorScheme).toBe('dark') - expect(localStorage.getItem(STORAGE_KEY)).toBe('dark') + expect(persist).toHaveBeenCalledWith('dark') expect(events).toHaveLength(1) expect(events[0]).toBe(theme.getTheme()) // The service never touches presentation state. @@ -39,13 +40,17 @@ describe('ThemeService', () => { // Same-value set is a no-op (no extra event). theme.setTheme('dark') expect(events).toHaveLength(1) + expect(persist).toHaveBeenCalledOnce() }) - it('restores a persisted preference and falls back on garbage', () => { - localStorage.setItem(STORAGE_KEY, 'dark') - expect(make().theme.getTheme().preference).toBe('dark') - localStorage.setItem(STORAGE_KEY, 'sepia') - expect(make().theme.getTheme().preference).toBe('system') + it('syncs a Host preference without writing it back', () => { + const { theme, events, persist } = make() + theme.syncPreference('dark') + expect(theme.getTheme().preference).toBe('dark') + expect(events).toHaveLength(1) + expect(persist).not.toHaveBeenCalled() + theme.syncPreference('dark') + expect(events).toHaveLength(1) }) it('throws on unknown setTheme ids, duplicate registration, and the system id', () => { @@ -56,7 +61,7 @@ describe('ThemeService', () => { }) it('registered themes join the snapshot; disposing the active one resets to default', () => { - const { theme, events } = make() + const { theme, events, persist } = make() const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: { '--dsw-alias-bg-base': 'red' } }) expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark', 'sepia']) theme.setTheme('sepia') @@ -64,7 +69,10 @@ describe('ThemeService', () => { dispose() expect(theme.getTheme().preference).toBe('system') expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark']) - expect(localStorage.getItem(STORAGE_KEY)).toBe('system') + // Custom ids are in-process extension themes; only the built-in product + // preferences cross the Host settings schema. + expect(persist).toHaveBeenCalledTimes(1) + expect(persist).toHaveBeenCalledWith('system') // register + set + dispose = three publishes; disposer is idempotent. expect(events.length).toBe(3) dispose() @@ -88,16 +96,11 @@ describe('ThemeService', () => { expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4]) }) - it('runs without localStorage (node boots): defaults on read, no-op on write', () => { - vi.stubGlobal('localStorage', undefined) - try { - const { theme } = make() - expect(theme.getTheme().preference).toBe('system') - theme.setTheme('dark') - expect(theme.getTheme().preference).toBe('dark') - } finally { - vi.unstubAllGlobals() - } + it('uses a no-op persistence callback when constructed directly', () => { + const ctx = new Context() + const theme = new ThemeService(ctx) + theme.setTheme('dark') + expect(theme.getTheme().preference).toBe('dark') }) describe('prefers-color-scheme resolution (stubbed matchMedia)', () => { diff --git a/packages/client/ui-theme/tsconfig.json b/packages/client/ui-theme/tsconfig.json index 7d5cc6f235..6b15b210d6 100644 --- a/packages/client/ui-theme/tsconfig.json +++ b/packages/client/ui-theme/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../connection" + }, { "path": "../locale" }, @@ -23,6 +26,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 38c79f4617..5035572ba4 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: 0963476a767801b465a6ead24feb0ecc9988b5f5 -README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9 +README.md: c1e818fa8ff52b10e722d9fd450073a6aede85da +README.zh.md: dfac19fa04d6b934c86733cb2a075017740ed37a diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0963476a76..c1e818fa8f 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -36,7 +36,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `permission` and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission`, `ui-theme`, or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e3634c5f92..dfac19fa04 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -36,7 +36,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission`、`ui-theme` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f528b2297e..c03d54caab 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -74,7 +74,7 @@ import { openNativePath, openNativeTextFile } from './native-path-opener.ts' const DEFAULT_MAX_MESSAGES = 50 /** Non-model settings namespaces intentionally served to the Web client. */ -const WEB_SETTINGS_NAMESPACES = ['permission'] as const +const WEB_SETTINGS_NAMESPACES = ['permission', 'ui-theme'] as const /** Provider work budget: at most 100 calls and 2,000 inspected hits. */ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 54235c0218..cc16519f65 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -308,8 +308,8 @@ describe('settings domain', () => { // The settings seam is general: any plugin may register a namespace for // its own configuration. The Web configuration plane remains opt-in, so a // future internal plugin cannot become remotely configurable just by - // registering; permission and the product onboarding namespace are the - // non-model namespaces intentionally admitted by this surface. + // registering; permission, theme, and the product onboarding namespace + // are the non-model namespaces intentionally admitted by this surface. const ctx = await harness() ctx.settings.register(NS, AdapterConfig) ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() })) @@ -318,15 +318,23 @@ describe('settings domain', () => { }), { base: { defaultPreset: 'read-only' }, }) + ctx.settings.register(settingsNamespace('ui-theme'), z.object({ + preference: z.union(['light', 'dark', 'system']).default('system'), + })) const api = createApiProxy(ctx, DEFAULTS) const value = expectOk(await api.settings.describe(request({}))) - expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission']) + expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission', 'ui-theme']) const permission = expectOk(await api.settings.mutate(request({ ns: 'permission', ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], }))) expect(permission.value).toEqual({ defaultPreset: 'workspace-write' }) + const theme = expectOk(await api.settings.mutate(request({ + ns: 'ui-theme', + ops: [{ op: 'set', path: ['preference'], value: 'dark' }], + }))) + expect(theme.value).toEqual({ preference: 'dark' }) for (const response of [ await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })), @@ -340,19 +348,29 @@ describe('settings domain', () => { expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({}) }) - it('serves the product onboarding namespace without invalidating the model catalog', async () => { + it('serves product preference namespaces without invalidating the model catalog', async () => { const ctx = await harness() ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() })) + ctx.settings.register(settingsNamespace('ui-theme'), z.object({ + preference: z.union(['light', 'dark', 'system']).default('system'), + })) const api = createApiProxy(ctx, DEFAULTS) expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) - .toEqual(['ui-onboarding']) - const frames = await collectHost(api, ['host/settings-changed'], 1, async () => { + .toEqual(['ui-onboarding', 'ui-theme']) + const frames = await collectHost(api, ['host/settings-changed'], 2, async () => { expectOk(await api.settings.mutate(request({ ns: 'ui-onboarding', ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }], }))) + expectOk(await api.settings.mutate(request({ + ns: 'ui-theme', + ops: [{ op: 'set', path: ['preference'], value: 'dark' }], + }))) }) - expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }]) + expect(frames).toEqual([ + { type: 'host/settings-changed', ns: 'ui-onboarding' }, + { type: 'host/settings-changed', ns: 'ui-theme' }, + ]) }) it('refuses even a model-provider namespace once its directory entry is gone', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aaaed3c423..cffbbaf2bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2130,10 +2130,19 @@ importers: packages/client/ui-theme: dependencies: + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings clsx: specifier: ^2.0.0 version: 2.1.1 + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale From 40a4c45e865afe677067771e1d8a9d9b7caa42f8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 21:03:38 +0800 Subject: [PATCH 039/229] test(ui-layout): provide theme connection seam --- packages/client/ui-layout/tests/apply.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index 903591163c..a82ea083e3 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -18,9 +18,10 @@ import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' async function bench() { const ctx = new Context() const slotsFiber = ctx.plugin(SlotsService) - // Theme now injects ['slots', 'locale'] (it registers its Appearance - // settings row); seat a real locale service so the theme fiber activates. + // Theme registers its Appearance settings row and requires the connection + // seam for persistence; model this bench as a remote, memory-only browser. ctx.provide('locale', new LocaleService(ctx)) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await ctx.plugin({ inject: themeInject, apply: themeApply }).await() await slotsFiber.await() return { ctx, slots: ctx.get('slots') as SlotsService } From a7e43d4346647546ea0f489566f130e45495d27e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 7 Aug 2026 14:26:50 +0800 Subject: [PATCH 040/229] refactor: drop the create-by-name workspace route The Web picker collapsed onto the directory flow (see the one-route-to-add-a-workspace Agent Note), leaving workspace.create({ name }) with no product consumer. Delete the whole feed line: the wire schema's name member and WorkspaceApi spelling, the gateway's workspaceRoot config/default and the mkdir branch, the client seam that carried the name (WorkspaceCreateInput, WorkspacesService.create, intentName), the dsh web --workspace-root flag, and the fixture's name handling. workspace-name-conflict stays as workspace.rename's duplicate-title error. --- ...-31-one-route-to-add-a-workspace.i18n.yaml | 4 +- ...2026-07-31-one-route-to-add-a-workspace.md | 2 +- ...6-07-31-one-route-to-add-a-workspace.zh.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/src/args.ts | 8 +- apps/cli/src/web.ts | 10 +-- apps/cli/tests/args.spec.ts | 4 +- docs/config-catalog.md | 6 +- packages/bundle/web-app/cordis.patch.yml | 2 +- .../client/connection/src/client/fixture.ts | 9 +- .../client/connection/tests/fixture.spec.ts | 21 ++--- .../runtime/src/client/contract/workspaces.ts | 6 +- .../runtime/src/client/workspaces/manager.ts | 2 +- .../runtime/src/client/workspaces/service.ts | 6 +- .../src/client/workspaces/workspace.ts | 3 +- .../runtime/tests/workspaces-service.spec.ts | 6 +- .../client/test-runtime/src/workspaces.ts | 11 ++- .../test-runtime/tests/runtime.spec.tsx | 6 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/README.zh.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 74 +--------------- .../host/apiproxy/src/api/workspace.schema.ts | 10 +-- packages/host/apiproxy/src/api/workspace.ts | 19 ++--- packages/host/apiproxy/src/index.ts | 12 +-- .../apiproxy/tests/api-proxy-approval.spec.ts | 4 +- .../apiproxy/tests/api-proxy-blank.spec.ts | 2 +- .../apiproxy/tests/api-proxy-cold.spec.ts | 22 ++--- .../apiproxy/tests/api-proxy-commands.spec.ts | 2 +- .../apiproxy/tests/api-proxy-config.spec.ts | 2 +- .../apiproxy/tests/api-proxy-fork.spec.ts | 1 - .../apiproxy/tests/api-proxy-models.spec.ts | 4 +- .../tests/api-proxy-projections.spec.ts | 2 +- .../apiproxy/tests/api-proxy-question.spec.ts | 2 +- .../apiproxy/tests/api-proxy-rename.spec.ts | 2 +- .../apiproxy/tests/api-proxy-search.spec.ts | 2 +- .../tests/api-proxy-subagents.spec.ts | 2 +- .../apiproxy/tests/api-proxy-view.spec.ts | 10 +-- .../tests/api-proxy-workspace.spec.ts | 85 +++++++++---------- .../apiproxy/tests/client-handler.spec.ts | 4 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 6 +- .../todo/tool-todo/tests/projection.spec.ts | 2 +- 44 files changed, 145 insertions(+), 252 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml index 1c0cc5644d..691511d76b 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md -2026-07-31-one-route-to-add-a-workspace.md: 5d002265b5eb1178bb1dbc7bd17f8b364d9b9856 -2026-07-31-one-route-to-add-a-workspace.zh.md: 0a59d3a505eb921b4ec980abaefedfcad8a3c294 +2026-07-31-one-route-to-add-a-workspace.md: 853d641e0a2c0044ee7bfd6ed42bcc3763520192 +2026-07-31-one-route-to-add-a-workspace.zh.md: 6a2884d75138ddc241276131f7ff85080fc5d794 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md index 5d002265b5..853d641e0a 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md @@ -27,7 +27,7 @@ The direct-open path carries the busy rule the menu entry states: while a pick i ## Wire and CLI residue -The host's `workspace.create` still accepts `{ name }`, and `dsh web --workspace-root` still feeds its target directory, but no product surface reaches either any more. The same is true of the client seam that carried the name to the wire: `WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, `intentName`'s name branch, and the manager's "name under workspaceRoot" contract. `apps/cli/README.md` and its Chinese counterpart still document `--workspace-root` as creating named Workspaces. The whole set is marked for deletion at the call site in `packages/host/apiproxy/src/api-proxy.ts` and left to a follow-up change: it is backend, client-seam, and CLI surface with its own reviewer and its own test fallout (the api-proxy workspace suite, the runtime workspace suite, the config catalog), and the release-blocking part of this decision is the UI. +Deleted in the follow-up change this section used to scope: `workspace.create` accepts only `{ path }` (the `name` member left the wire schema and `WorkspaceApi`), the gateway lost its `workspaceRoot` config and default, the client seam narrowed to the path spelling (`WorkspaceCreateInput`, `WorkspacesService.create`, `intentName`), and the `dsh web --workspace-root` flag is gone together with its `apps/cli` reference lines. `workspace-name-conflict` remains on the wire as `workspace.rename`'s duplicate-title error. ## Testing diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md index 0a59d3a505..6a2884d751 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md @@ -27,7 +27,7 @@ Status: implemented ## Wire and CLI residue -Host 侧的 `workspace.create` 仍接受 `{ name }`,`dsh web --workspace-root` 也仍在为它提供目标目录,但已没有任何产品表层会走到它们。把名称送到 wire 的客户端一段同样如此:`WorkspaceCreateInput`、`WorkspacesService.create` 的 `{ name }` 分支、`intentName` 的名称分支,以及 manager 中"workspaceRoot 下的 name"这一契约。`apps/cli/README.md` 及其中文对照本也仍把 `--workspace-root` 记为"创建具名 Workspace"。这一整套都在 `packages/host/apiproxy/src/api-proxy.ts` 的调用点标记为待删除,并留给后续改动:它横跨 backend、客户端 seam 与 CLI 面,有各自的 reviewer 和各自的测试波及面(api-proxy workspace 套件、runtime workspace 套件、配置目录),而本决定中阻塞发布的部分是 UI。 +本节曾划定的后续删除已经落地:`workspace.create` 只接受 `{ path }`(`name` 成员已从 wire schema 与 `WorkspaceApi` 移除),网关失去了 `workspaceRoot` 配置及其默认值,客户端 seam 收窄为 path 写法(`WorkspaceCreateInput`、`WorkspacesService.create`、`intentName`),`dsh web --workspace-root` flag 连同其 `apps/cli` reference 文档行一并删除。`workspace-name-conflict` 仍留在 wire 上,作为 `workspace.rename` 的重名错误。 ## Testing diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 07d7810529..db6a4c6dbe 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 8b8a0e7dbebafedd6a4f8d988adb3fd11c7bd026 -README.zh.md: d1d6d5a594596a8be5db30021163f0fcea4a95bf +README.md: 9adf7e4b238a96c5497c14709ba7ddc08eff13af +README.zh.md: d4f2c607f1b6067c0d936761cb86301950597e1d diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 8b8a0e7dbe..9adf7e4b23 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -37,7 +37,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index d1d6d5a594..d4f2c607f1 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -37,7 +37,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port`、`--workspace-root` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 310b5b03a2..349a127916 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -40,7 +40,6 @@ interface WebInvocation { host?: string port?: number dev: boolean - workspaceRoot?: string /** Extra authorities for the /api browser-trust fence. */ trustedHosts?: string[] } @@ -62,7 +61,6 @@ interface WebOptions { host?: string port?: string dev?: boolean - workspaceRoot?: string trustedHost?: string[] dumpConfig?: boolean dumpDefaultConfig?: boolean @@ -152,7 +150,6 @@ Examples: .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') - .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit') .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit') @@ -172,8 +169,8 @@ Examples: // dropping them would print a tree that differs from the same // invocation's boot. if (options.host !== undefined || options.port !== undefined || options.dev === true - || options.workspaceRoot !== undefined || options.trustedHost !== undefined) { - program.error('error: config dumps take no web flags (--host/--port/--dev/--workspace-root/--trusted-host)') + || options.trustedHost !== undefined) { + program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)') } resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches } return @@ -187,7 +184,6 @@ Examples: ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, - ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, } }) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 64051416c4..ca6a53c3e0 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,7 +1,7 @@ /** * `dsh web` — the browser-surface alias over the profile boot: `--profile web` - * plus the Web flag family (`--host/--port/--dev/--workspace-root/ - * --trusted-host`), each flag becoming a patch over the composed profile + * plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag + * becoming a patch over the composed profile * tree. All web runtime glue (dist serving, prompt section, URL line) lives * in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives * flag patches and the LAN-trust snapshot. @@ -58,7 +58,6 @@ export interface WebFlags { host?: string port?: number dev: boolean - workspaceRoot?: string trustedHosts?: string[] } @@ -82,7 +81,6 @@ function deriveWebFlagPatches( } if (flags.host !== undefined) put('webserver', 'host', flags.host) if (flags.port !== undefined) put('webserver', 'port', flags.port) - if (flags.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', flags.workspaceRoot) const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? []) if (trustedHosts.length > 0) { @@ -118,8 +116,8 @@ export function webSurfaceContextEnabled(rows: ProfileRows): boolean { } /** - * Serve the browser UI from the web profile. Host/port/workspace-root flags - * are passed through only when given (absent, the composed profile values + * Serve the browser UI from the web profile. Host/port flags are passed + * through only when given (absent, the composed profile values * stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on * every boot. The URL line is printed by the web-app bundle's runtime row * after Loader settlement. diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 93bfb62cc6..66e1a0db40 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -29,8 +29,8 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] }) expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) - expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) - .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w', patches: [] }) + expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev'])) + .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] }) expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) }) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 13ebcc5b32..edb1576689 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -577,18 +577,16 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` ```ts config-catalog -/** Gateway plugin config: host-level agent routing and Workspace creation root. */ +/** Gateway plugin config: host-level agent routing. */ export interface Config { /** Default provider route for created/resumed agents. */ provider: string /** Default model id. */ model: string - /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ - workspaceRoot?: string } ``` -Source: [`packages/host/apiproxy/src/index.ts:33`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:32`](../packages/host/apiproxy/src/index.ts) ## `@deepseek-ai/dsh-host-directory-picker-browse` diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 624e9e37af..ddb5c97b7b 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -4,7 +4,7 @@ # # A patch replaces the targeted row's whole `config`, so each row below # restates every key it owns. The `dsh web` launcher alias turns --host/--port/ -# --dev/--workspace-root/--trusted-host into further patches over these rows +# --dev/--trusted-host into further patches over these rows # (`--dev` inserts the dsh-client-hmr row). # ── surface-specific values the base deliberately omits ───────────────────── diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dc2f8c5967..f92a0da870 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2122,15 +2122,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { archivedSessionIds: [...archivedSessionIds], }), create: (request) => { - const { path, name } = request.payload - const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}` - const existing = workspaces.find(w => w.path === target) + const { path } = request.payload + const existing = workspaces.find(w => w.path === path) if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false }) const now = new Date().toISOString() const created: WorkspaceView = { workspaceId: wid(`fx-ws-${nextWorkspace++}`), - path: target, - title: name ?? target.split('/').filter(Boolean).at(-1) ?? target, + path, + title: path.split('/').filter(Boolean).at(-1) ?? path, sessionIds: [], createdAt: now, updatedAt: now, diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index f608190936..5b9824fcde 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -546,7 +546,7 @@ describe('createFixtureApi', () => { } })() await new Promise(resolve => setTimeout(resolve, 10)) - const created = await api.workspace.create(req({ name: 'nova' })) + const created = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) if (!created.result.ok) throw new Error('create failed') expect(created.result.value.created).toBe(true) expect(created.result.value.workspace).toMatchObject({ @@ -554,16 +554,7 @@ describe('createFixtureApi', () => { }) await consuming expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }]) - // path spelling falls back to the basename when no title/name rides along. - const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' })) - if (!pathOnly.result.ok) throw new Error('pathOnly failed') - expect(pathOnly.result.value.workspace.title).toBe('base') - // Degenerate spellings reach the impl unfiltered (the fixture carrier has - // no schema gate): both-absent falls back to the bucket dir, and a - // basename-less path serves as its own title. - const bare = await api.workspace.create(req({})) - if (!bare.result.ok) throw new Error('bare failed') - expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' }) + // A basename-less path serves as its own title. const rootPath = await api.workspace.create(req({ path: '/' })) if (!rootPath.result.ok) throw new Error('rootPath failed') expect(rootPath.result.value.workspace.title).toBe('/') @@ -584,7 +575,7 @@ describe('createFixtureApi', () => { const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) - await api.workspace.create(req({ name: 'occupied' })) + await api.workspace.create(req({ path: '/tmp/fixture-workspaces/occupied' })) const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' })) expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } }) @@ -722,7 +713,7 @@ describe('createFixtureApi', () => { expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } }) expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } }) - const made = await api.workspace.create(req({ name: 'nova' })) + const made = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) if (!made.result.ok) throw new Error('workspace create failed') const abort = new AbortController() const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2) @@ -991,7 +982,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) expect((await client.workspace.list({})).result.ok).toBe(true) - const workspace = await client.workspace.create({ name: 'via-client' }) + const workspace = await client.workspace.create({ path: '/tmp/fixture-workspaces/via-client' }) if (!workspace.result.ok) throw new Error('workspace create failed') expect(workspace.result.value.workspace.title).toBe('via-client') const wsid = workspace.result.value.workspace.workspaceId @@ -1049,7 +1040,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { }) const client = new FixtureApiClient() await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) - const made = await client.workspace.create({ name: 'query-workspace' }) + const made = await client.workspace.create({ path: '/tmp/fixture-workspaces/query-workspace' }) if (!made.result.ok) throw new Error('workspace create failed') const abort = new AbortController() const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2) diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 3e64ef3717..ad896bbdaf 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -27,11 +27,11 @@ export interface IWorkspaces { */ startSession(workspaceId?: WorkspaceId): void /** - * Create a Workspace by name or register an existing path. - * @param input - exactly one Host create spelling. + * Register an existing path as a Workspace. + * @param input - the Host create payload. * @returns the created or idempotently resolved Workspace. */ - create(input: { name: string } | { path: string }): Promise + create(input: { path: string }): Promise /** * Open the Host's native directory picker. * @returns the selected path, or null when the user cancelled. diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index ccf0c46fe1..df3aa8fe28 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -120,7 +120,7 @@ export class WorkspaceManager { /** * Create or resolve a real Workspace, then publish its returned snapshot * without waiting for the changed frame. - * @param input - name under workspaceRoot or an existing absolute path. + * @param input - the existing absolute path to adopt. * @returns the wire result. */ async create(input: WorkspaceCreateInput): Promise> { diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 69910fd0c4..c0f71b92db 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -186,11 +186,11 @@ export class WorkspacesService implements IWorkspaces { } /** - * Create a Workspace by name or register an existing path. - * @param input - exactly one Host create spelling. + * Register an existing path as a Workspace. + * @param input - the Host create payload. * @returns the created or idempotently resolved Workspace. */ - async create(input: { name: string } | { path: string }): Promise { + async create(input: { path: string }): Promise { const result = await this.manager.create(input) if (!result.ok) throw new WorkspaceCreateError(result.error) return result.value.workspace diff --git a/packages/client/runtime/src/client/workspaces/workspace.ts b/packages/client/runtime/src/client/workspaces/workspace.ts index afa4dd65b6..f6657c7053 100644 --- a/packages/client/runtime/src/client/workspaces/workspace.ts +++ b/packages/client/runtime/src/client/workspaces/workspace.ts @@ -8,7 +8,7 @@ import type { ObservableSnapshot } from '../contract/store.ts' import { Notifier } from '../sessions/notifier.ts' /** Host input retained by a local Workspace until materialization succeeds. */ -export type WorkspaceCreateInput = { name: string } | { path: string } +export type WorkspaceCreateInput = { path: string } /** Observable state of a client-local Workspace intent. */ export interface WorkspaceIntentSnapshot { @@ -137,7 +137,6 @@ export class Workspace implements ObservableSnapshot { } function intentName(input: WorkspaceCreateInput): string { - if ('name' in input) return input.name const trimmed = input.path.replace(/[\\/]+$/, '') return trimmed.split(/[\\/]/).pop() ?? input.path } diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 832a1ff71a..dd38a3119c 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -59,7 +59,7 @@ describe('WorkspaceManager', () => { expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } }) }) - it('creates by name/path, prepends a new row, and folds failures', async () => { + it('creates by path, prepends a new row, and folds failures', async () => { const api = new FakeApiClient() const manager = new WorkspaceManager(api) api.onWorkspaceCreate = payload => Promise.resolve(ok({ @@ -67,8 +67,8 @@ describe('WorkspaceManager', () => { created: true, payload, } as never)) - await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true }) - expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }]) + await expect(manager.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true }) + expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/created' }]) expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created') api.onWorkspaceCreate = () => Promise.reject(new Error('create transport')) diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 7e626a3660..9e1061ec8c 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -73,18 +73,17 @@ export class TestWorkspaces implements IWorkspaces { /** * Create a Workspace (recorded). The default echoes a view derived from * the input; stub for failure or list-coupled flows. - * @param input - exactly one Host create spelling. + * @param input - the Host create payload. * @returns the created Workspace view. */ - async create(input: { name: string } | { path: string }): Promise { + async create(input: { path: string }): Promise { this.calls.push({ method: 'create', args: [input] }) const stub = this.stubs.get('create') if (stub !== undefined) return await (stub(input) as Promise) - const title = 'name' in input ? input.name : input.path return { - workspaceId: `ws-${title}` as WorkspaceId, - title, - path: 'path' in input ? input.path : `/${input.name}`, + workspaceId: `ws-${input.path}` as WorkspaceId, + title: input.path, + path: input.path, sessionIds: [], } as unknown as WorkspaceView } diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index f92d21b4b5..d0b3d75d52 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -559,8 +559,8 @@ describe('workspaces action face', () => { it('records every IWorkspaces verb with inert defaults and honors stubs', async () => { const runtime = await SlotTestRuntime.create() const ws = runtime.workspaces - const created = await ws.create({ name: 'alpha' }) - expect(created.title).toBe('alpha') + const created = await ws.create({ path: '/tmp/alpha' }) + expect(created.title).toBe('/tmp/alpha') const registered = await ws.create({ path: '/tmp/beta' }) expect(registered.path).toBe('/tmp/beta') await expect(ws.pickDirectory()).resolves.toBeNull() @@ -584,7 +584,7 @@ describe('workspaces action face', () => { ws.stub('openPath', () => Promise.resolve()) ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never)) ws.stub('archiveSession', () => Promise.resolve()) - expect((await ws.create({ name: 'y' })).title).toBe('X') + expect((await ws.create({ path: '/y' })).title).toBe('X') await expect(ws.pickDirectory()).resolves.toBe('/picked') expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S') await ws.delete('w1' as WorkspaceId) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 38c79f4617..97aad6680c 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: 0963476a767801b465a6ead24feb0ecc9988b5f5 -README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9 +README.md: 1caf9f2ee61fbf36a18b18ff2d1e7e230ee4b7f6 +README.zh.md: c42312bb9972c4c5b02ec4dbc5c7d4402921a9c1 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0963476a76..1caf9f2ee6 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml). +The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml). ## Contract layer (`/api`) @@ -24,7 +24,7 @@ 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. -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()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e3634c5f92..c42312bb99 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。 +所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。 ## 契约层(`/api`) @@ -24,7 +24,7 @@ 待处理的 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 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 -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()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 19fb0fe8a2..5ad2318945 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -5,7 +5,6 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' -import { join } from 'node:path' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent' @@ -333,8 +332,6 @@ export interface ApiProxyDefaults { model: string /** Default project directory for new sessions whose create request carries no cwd. */ cwd: string - /** Parent directory for name-created workspaces. */ - workspaceRoot: string /** Native open-with-default-application; injectable for carrier tests. */ openPath?: (path: string, signal: AbortSignal) => Promise /** Native text-editor handoff; injectable for settings-document tests. */ @@ -668,9 +665,6 @@ class SessionCwdConflict extends Error { } } -/** Host failed before the registry could adopt a name-created directory. */ -class WorkspaceDirectoryCreationError extends Error {} - /** An explicit Host naming operation would duplicate another Workspace title. */ class WorkspaceNameConflictError extends Error { constructor(readonly workspaceName: string) { @@ -1183,29 +1177,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } /** Resolve or create one path while holding the Host's workspace-create chain. */ - function ensureWorkspace( - path: string, - title: string | undefined, - rejectExistingName = false, - createDirectory = false, - ): Promise<{ workspace: Workspace; created: boolean }> { + function ensureWorkspace(path: string): Promise<{ workspace: Workspace; created: boolean }> { const operation = workspaceCreationChain.then(async () => { - if (rejectExistingName && title !== undefined - && ctx.workspace.list().some(workspace => workspace.title === title)) { - throw new WorkspaceNameConflictError(title) - } - if (createDirectory) { - try { - await mkdir(path, { recursive: true }) - } catch (error: unknown) { - throw new WorkspaceDirectoryCreationError( - `failed to create workspace directory "${path}": ${String(error)}`, - ) - } - } const existing = await ctx.workspace.resolveByPath(path) if (existing !== undefined) return { workspace: existing, created: false } - return { workspace: await ctx.workspace.create(path, title), created: true } + return { workspace: await ctx.workspace.create(path), created: true } }) workspaceCreationChain = operation.then(() => undefined, () => undefined) return operation @@ -2035,54 +2011,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro })) }, - // Exactly one of path/name arrives (schema refine). Existing-folder - // adoption reuses its canonical path; create-by-name rejects a name - // already present in the registry. - // TODO: the create-by-name branch lost its last product consumer when - // the Web picker collapsed onto the directory flow - // (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md). - // Delete it with the wire schema's `name` member, this - // `defaults.workspaceRoot`, the client seam that carried the name - // (`WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, - // `intentName`'s name branch, the manager's "name under workspaceRoot" - // contract), and the `dsh web --workspace-root` flag plus its apps/cli - // README lines, which exist only to feed it. async create(request) { - const { payload } = request - let path: string - if (payload.name !== undefined) { - const name = payload.name.trim() - if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) { - return err(request, { - code: 'workspace-invalid-path', - message: `workspace name must be one non-empty path segment, got "${payload.name}"`, - details: { path: payload.name }, - }) - } - path = join(defaults.workspaceRoot, name) - } else { - path = payload.path as string - } + const { path } = request.payload try { - const name = payload.name?.trim() - const { workspace, created } = await ensureWorkspace( - path, - name, - name !== undefined, - name !== undefined, - ) + const { workspace, created } = await ensureWorkspace(path) return ok(request, { workspace: workspaceView(workspace), created }) } catch (error: unknown) { - if (error instanceof WorkspaceNameConflictError) { - return err(request, { - code: 'workspace-name-conflict', - message: error.message, - details: { name: error.workspaceName }, - }) - } - if (error instanceof WorkspaceDirectoryCreationError) { - return err(request, { code: 'internal', message: error.message, details: {} }) - } // The registry rejects a path that does not resolve to an existing // directory (realpath ENOENT / not-a-directory) — the business // error of the typed-path flow, surfaced as a validation failure. diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index 20b3038301..5ad5a0b96b 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -31,14 +31,10 @@ export const workspaceListValueSchema = z.object({ archivedSessionIds: z.array(sessionIdSchema), }) satisfies z.ZodType>> -/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */ +/** workspace.create request payload: the existing directory to adopt. */ export const workspaceCreateRequestSchema = z.object({ - path: z.string().optional(), - name: z.string().optional(), -}).refine( - payload => (payload.path === undefined) !== (payload.name === undefined), - { message: 'workspace.create requires exactly one of path / name' }, -) satisfies z.ZodType>> + path: z.string(), +}) satisfies z.ZodType>> /** workspace.create response value. */ export const workspaceCreateValueSchema = z.object({ diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index d5307e27a8..64feb27f80 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -46,19 +46,14 @@ export interface WorkspaceApi { list(request: RpcRequest<{}>): Promise> /** - * Creates (or idempotently resolves) a workspace. Exactly one of `path` / - * `name` (schema-enforced): `path` registers an EXISTING directory (no - * mkdir — a missing or non-directory path fails with `workspace-invalid-path`); - * `name` is a single path segment the host mkdirs under its default project - * root before registering. Either spelling resolving to a directory already - * owned by a workspace returns that workspace (`created: false`) for the - * existing-folder spelling. Create-by-name rejects an existing title with - * `workspace-name-conflict`; path adoption allows distinct canonical paths - * whose basenames produce the same display title. - * A new name-created workspace uses `name` as both directory name and title; - * a path-created workspace uses the registry's basename title default. + * Creates (or idempotently resolves) a workspace over an EXISTING directory + * (no mkdir — a missing or non-directory path fails with + * `workspace-invalid-path`). A path resolving to a directory already owned + * by a workspace returns that workspace (`created: false`). Adoption allows + * distinct canonical paths whose basenames produce the same display title; + * the registry's basename title default names the new workspace. */ - create(request: RpcRequest<{ path?: string; name?: string }>): + create(request: RpcRequest<{ path: string }>): Promise> /** diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index e279575ff4..a649dabccb 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -8,7 +8,6 @@ * routes — physical carriers wrap `ctx.apiProxy` themselves. */ -import { resolve } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' import type { ApiProxy } from './api/index.ts' @@ -29,20 +28,18 @@ declare module 'cordis' { } } -/** Gateway plugin config: host-level agent routing and Workspace creation root. */ +/** Gateway plugin config: host-level agent routing. */ export interface Config { /** Default provider route for created/resumed agents. */ provider: string /** Default model id. */ model: string - /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ - workspaceRoot?: string } /** * The API gateway service: implements the ApiProxy contract over the composed * host context and provides it as `ctx.apiProxy`. The Host cwd is the default - * project directory and the fallback parent for name-created Workspaces. + * project directory. */ export class ApiProxyService extends Service implements ApiProxy { static inject = [ @@ -53,7 +50,6 @@ export class ApiProxyService extends Service implements ApiProxy { static Config: z = z.object({ provider: z.string().required(), model: z.string().required(), - workspaceRoot: z.string(), }) readonly sessions: ApiProxy['sessions'] @@ -71,12 +67,10 @@ export class ApiProxyService extends Service implements ApiProxy { constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') - const cwd = process.cwd() const api = createApiProxy(ctx, { provider: config.provider, model: config.model, - cwd, - workspaceRoot: resolve(config.workspaceRoot ?? cwd), + cwd: process.cwd(), }) this.sessions = api.sessions this.subagents = api.subagents diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index 4833667583..4734d4e457 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) await ctx.plugin(ApprovalService) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) return { ctx, api } } @@ -217,7 +217,7 @@ describe('approval pending registry', () => { await ctx.plugin(ApprovalService) let api!: ApiProxy const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => { - api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp' }) }, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] })) await fiber.await() const abort = new AbortController() diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index 4f8637068e..008d35d568 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts @@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio await ctx.plugin(AgentRegistry) return { ctx, - api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }), attach: (session) => { ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) }, diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 78a67ef642..14f7905633 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -62,7 +62,7 @@ describe('sessions.list cold merge', () => { return undefined }, }) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const response = await api.sessions.list(request({})) expect(response.result.ok).toBe(true) @@ -90,7 +90,7 @@ describe('attached updatedAt excludes end-seed', () => { await ctx.plugin(SessionStore) await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) // Old work, resumed just now: the log tail would report the pickup. const worked = 1_000_000 @@ -148,7 +148,7 @@ describe('cold history recovery view', () => { inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal), locate: () => undefined, } as never) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 })) if (!history.result.ok) throw new Error('history failed') @@ -216,7 +216,7 @@ describe('subagent ownership fence', () => { locate: () => undefined, } as never) const resume = vi.spyOn(ctx.agents, 'resume') - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const history = await api.sessions.history(request({ sessionId })) expect(history.result.ok).toBe(true) @@ -275,7 +275,7 @@ describe('subagent ownership fence', () => { // instead of answering `agent-busy`. const resume = vi.spyOn(ctx.agents, 'resume') .mockRejectedValue(new Error('registry unavailable in this bench')) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const prompt = await api.sessions.prompt(request({ sessionId, @@ -316,7 +316,7 @@ describe('subagent ownership fence', () => { }) const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent ctx.agents.enter(startingChild, parent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const stopped = await api.sessions.cancel(request({ sessionId: originChild.id })) expect(stopped.result.ok).toBe(false) @@ -362,7 +362,7 @@ describe('subagent ownership fence', () => { const followup = vi.fn() const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent ctx.agents.register(agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const response = await api.sessions.prompt(request({ sessionId: agent.id, @@ -380,7 +380,7 @@ describe('degenerate composition (no persistence, no factory)', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const listed = await api.sessions.list(request({})) expect(listed.result.ok).toBe(true) @@ -405,7 +405,7 @@ describe('degenerate composition (no persistence, no factory)', () => { list: () => Promise.resolve([]), inspect, } as never) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const response = await api.sessions.history(request({ sessionId: sid('session-missing') })) expect(response.result.ok).toBe(false) @@ -431,7 +431,7 @@ describe('sessions.prompt synchronous rejection', () => { followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, } as unknown as Agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) for (const mode of ['queue', 'steer'] as const) { const response = await api.sessions.prompt(request({ @@ -475,7 +475,7 @@ describe('sessions.prompt synchronous rejection', () => { ctx.agents.register(child) throw new Error('session id already published') }) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const models = await api.sessions.models(request({ sessionId })) expect(models.result.ok).toBe(false) diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 1ab33897e3..8b4866eba9 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp' } function request

(payload: P): RpcRequest

{ return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 54235c0218..e6c799a236 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -24,7 +24,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp' } let nextRpc = 1 function request

(payload: P): RpcRequest

{ diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index 83955f2d8b..4ac934e365 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -85,7 +85,6 @@ const api = (ctx: Context) => createApiProxy(ctx, { provider: 'default-provider', model: 'default-model', cwd: '/tmp', - workspaceRoot: '/tmp', }) describe('sessions.fork', () => { diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index c2dfdae7a7..87ea408bab 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -125,7 +125,7 @@ describe('Web session model selection', () => { model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp' }) const catalog = expectValue(await api.sessions.models(request({ sessionId }))) expect(catalog.current).toEqual({ @@ -160,7 +160,7 @@ describe('Web session model selection', () => { it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() - const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index a1775a8025..c1efc32c97 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void { } } -const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) describe('session.history projections block', () => { it('serves the unit value on the tail page with asOfSeq = last event seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts index e8eaae813f..c3ae82fe06 100644 --- a/packages/host/apiproxy/tests/api-proxy-question.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -13,7 +13,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) return { ctx, - api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }), } } diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 15c7361024..b3630eafb5 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session { return session } -const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) describe('sessions.rename', () => { it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 57bb05df4f..1160d4dd18 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { }) const sid = (value: string): SessionId => value as SessionId -const defaults = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const defaults = { provider: 'p', model: 'm', cwd: '/tmp' } function request(query: string): RpcRequest<{ query: string }> { return { rpcId: RpcId(`search-${query}`), payload: { query } } diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index c761484da5..580dfa280c 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -88,7 +88,7 @@ function bench(options: { ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} }) ctx.provide('userInteraction', { registerProvider: () => () => {} }) const api = createApiProxy(ctx, { - provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp', + provider: 'p', model: 'm', cwd: '/tmp', }) return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent } } diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 43083545db..0427443841 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable>, count: num describe('mux live view computation', () => { it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal) const collected = collect(stream, 9, abort) @@ -170,7 +170,7 @@ describe('mux live view computation', () => { it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const session = ctx.sessions.create() // history resolves the agent first; a live structural stub is enough (only // .session is read on this path). @@ -238,7 +238,7 @@ describe('mux live view computation', () => { it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) session.append('turn/start', { turn: 1 }) @@ -287,7 +287,7 @@ describe('mux live view computation', () => { it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal) @@ -308,7 +308,7 @@ describe('mux live view computation', () => { it('pairs a result after turn/end via the in-memory backscan fallback', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal) const collected = collect(stream, 4, abort) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index af315ffcd0..b548b36702 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent { /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ async function harness( - workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), + root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, extras: { openPath?: (path: string, signal: AbortSignal) => Promise } = {}, ) { @@ -102,11 +102,17 @@ async function harness( const api = createApiProxy(ctx, { provider: 'test', model: 'test-model', - cwd: workspaceRoot, - workspaceRoot, + cwd: root, ...extras.openPath === undefined ? {} : { openPath: extras.openPath }, }) - return { api, ctx, storageDomain, workspaceRoot } + return { api, ctx, storageDomain, root } +} + +/** Stage one directory under the harness root for path adoption. */ +function stageDir(root: string, name: string): string { + const path = join(root, name) + mkdirSync(path) + return path } describe('host.pickDirectory', () => { @@ -244,30 +250,26 @@ describe('host.openPath', () => { }) describe('workspace.create', () => { - it('serializes concurrent names and rejects the duplicate', async () => { - const { api, workspaceRoot } = await harness() + it('serializes concurrent creates of one path into a single registration', async () => { + const { api, root } = await harness() + const target = join(root, 'alpha') + mkdirSync(target) const responses = await Promise.all([ - api.workspace.create(request({ name: 'alpha' })), - api.workspace.create(request({ name: 'alpha' })), + api.workspace.create(request({ path: target })), + api.workspace.create(request({ path: target })), ]) - const created = responses.find(response => response.result.ok) - const duplicate = responses.find(response => !response.result.ok) + const values = responses.map(response => expectOk(response)) + const created = values.find(value => value.created) + const resolved = values.find(value => !value.created) - expect(created).toBeDefined() - expect(expectOk(created!)).toMatchObject({ - created: true, - workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' }, - }) - expect(duplicate?.result).toMatchObject({ - ok: false, - error: { code: 'workspace-name-conflict', details: { name: 'alpha' } }, - }) - expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true) + expect(created).toMatchObject({ workspace: { path: target, title: 'alpha' } }) + expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId) + expect(expectOk(await api.workspace.list(request({}))).items).toHaveLength(1) }) - it('adopts only existing directories and rejects unsafe names', async () => { - const { api, workspaceRoot } = await harness() - const existing = join(workspaceRoot, 'existing') + it('adopts only existing directories', async () => { + const { api, root } = await harness() + const existing = join(root, 'existing') mkdirSync(existing) const first = expectOk(await api.workspace.create(request({ path: existing }))) const repeated = expectOk(await api.workspace.create(request({ path: existing }))) @@ -281,21 +283,16 @@ describe('workspace.create', () => { const reopened = expectOk(await api.workspace.create(request({ path: existing }))) expect(reopened.workspace.title).toBe('renamed-existing') - const missing = join(workspaceRoot, 'missing') + const missing = join(root, 'missing') const missingResult = await api.workspace.create(request({ path: missing })) expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) expect(existsSync(missing)).toBe(false) - - for (const name of ['', '.', '..', 'a/b', 'a\\b']) { - const invalid = await api.workspace.create(request({ name })) - expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) - } }) it('adopts different paths that derive the same Workspace title', async () => { - const { api, workspaceRoot } = await harness() - const first = join(workspaceRoot, 'one', 'project') - const second = join(workspaceRoot, 'two', 'project') + const { api, root } = await harness() + const first = join(root, 'one', 'project') + const second = join(root, 'two', 'project') mkdirSync(first, { recursive: true }) mkdirSync(second, { recursive: true }) const firstResult = expectOk(await api.workspace.create(request({ path: first }))) @@ -316,8 +313,8 @@ describe('workspace.create', () => { describe('session creation and Workspace membership', () => { it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => { - const { api, ctx } = await harness() - const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const { api, ctx, root } = await harness() + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace const sessionId = SessionId('session-workspace-preallocated') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) @@ -343,8 +340,8 @@ describe('session creation and Workspace membership', () => { }) it('retains a published session when attachment fails and repairs it on retry', async () => { - const { api, ctx } = await harness() - const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const { api, ctx, root } = await harness() + const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace const workspace = ctx.workspace.list()[0] if (workspace === undefined) throw new Error('workspace missing from registry') vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure')) @@ -394,7 +391,7 @@ describe('Host Workspace increments', () => { }) it('streams committed Workspace and Session increments after empty baselines', async () => { - const { api } = await harness() + const { api, root } = await harness() expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) expect(expectOk(await api.sessions.list(request({}))).items).toEqual([]) @@ -402,7 +399,7 @@ describe('Host Workspace increments', () => { const stream: AsyncIterator> = api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() const workspaceIncrement = nextHostFrame(stream) - const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace expect(await workspaceIncrement).toMatchObject({ payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } }, }) @@ -430,7 +427,7 @@ describe('Host Workspace increments', () => { }) it('does not publish a Workspace whose registry-order commit fails', async () => { - const { api, storageDomain } = await harness() + const { api, storageDomain, root } = await harness() const domain = storageDomain.get('workspace') if (domain === undefined) throw new Error('workspace domain is not open') vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure')) @@ -439,7 +436,7 @@ describe('Host Workspace increments', () => { api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() const next = stream.next() - const failed = await api.workspace.create(request({ name: 'ghost' })) + const failed = await api.workspace.create(request({ path: stageDir(root, 'ghost') })) expect(failed.result.ok).toBe(false) expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) abort.abort() @@ -447,8 +444,8 @@ describe('Host Workspace increments', () => { }) it('deletes the registration, keeps its session and folder, and streams one removal', async () => { - const { api, ctx } = await harness() - const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace + const { api, ctx, root } = await harness() + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'delete-me') }))).workspace const sessionId = SessionId('session-kept-after-workspace-delete') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) @@ -480,8 +477,8 @@ describe('Host Workspace increments', () => { }) it('archives a session into the global set, keeps its accounting, and streams the set once', async () => { - const { api } = await harness() - const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace + const { api, root } = await harness() + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'archive-home') }))).workspace const sessionId = SessionId('session-to-archive') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([]) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 490e0ad7f1..a11e168f56 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -378,8 +378,8 @@ describe('workspace domain round trip', () => { expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } }) }) - it('rejects a create payload violating the exactly-one refine at the handler', async () => { - const response = await client(scriptedApi()).workspace.create({}) + it('rejects a pathless create payload at the handler schema', async () => { + const response = await client(scriptedApi()).workspace.create({} as never) expect(response.result.ok).toBe(false) if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index b65861c1ae..4639b5c5ee 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -324,11 +324,9 @@ describe('workspace domain schemas', () => { expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() }) - it('create requires exactly one of path/name (both refine arms)', () => { + it('create requires a path', () => { expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p') - expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n') - expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/) - expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/) + expect(() => workspaceCreateRequestSchema.parse({})).toThrow() expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) }) diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index f1932b9955..5bf96a22a1 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -45,7 +45,7 @@ async function harness(withTodoTool: boolean): Promise { if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) return { ctx, session, From 40ee7f5e27983e313271fa607d138460ab89b6a7 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 7 Aug 2026 14:48:26 +0800 Subject: [PATCH 041/229] docs,test: settle review follow-ups for the create-by-name deletion Rewrite the three sibling Agent Note pairs that still described create-by-name as current (workspace-ui-product-flow, session-list-browsing-and-manual-order, same-basename-workspace-adoption) and the one-route note's own alternative and section title; delete scripts/hero-composer-dom-continuity.mjs, which drove the name dialog removed by the one-route change; mark WorkspaceRegistry.create's now test-only title parameter with a deletion TODO; pin the retired { name } spelling as a schema rejection; align the workspace spec on stageDir and the fixture spec title on path creates. --- ...same-basename-workspace-adoption.i18n.yaml | 4 +- ...-07-31-same-basename-workspace-adoption.md | 4 +- ...-31-same-basename-workspace-adoption.zh.md | 4 +- ...n-list-browsing-and-manual-order.i18n.yaml | 4 +- ...-session-list-browsing-and-manual-order.md | 2 +- ...ssion-list-browsing-and-manual-order.zh.md | 2 +- ...-07-25-workspace-ui-product-flow.i18n.yaml | 4 +- .../2026-07-25-workspace-ui-product-flow.md | 7 +- ...2026-07-25-workspace-ui-product-flow.zh.md | 7 +- ...-31-one-route-to-add-a-workspace.i18n.yaml | 4 +- ...2026-07-31-one-route-to-add-a-workspace.md | 4 +- ...6-07-31-one-route-to-add-a-workspace.zh.md | 4 +- .../client/connection/tests/fixture.spec.ts | 2 +- .../tests/api-proxy-workspace.spec.ts | 6 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 2 + packages/workspace/workspace/src/index.ts | 5 ++ scripts/hero-composer-dom-continuity.mjs | 78 ------------------- 17 files changed, 34 insertions(+), 109 deletions(-) delete mode 100644 scripts/hero-composer-dom-continuity.mjs diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml index 990e7e39bb..e7bdf260be 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md -2026-07-31-same-basename-workspace-adoption.md: ed53804ea64df0d61db16e579c3d65af803dbb97 -2026-07-31-same-basename-workspace-adoption.zh.md: 82cfb7d90afca28f8e666742a758fda0202909f3 +2026-07-31-same-basename-workspace-adoption.md: 1192558632fdc8bd732ea59f0eca5051f49f5d2c +2026-07-31-same-basename-workspace-adoption.zh.md: 9c1f1ffd221936e24300b223ad395e1f44552810 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md index ed53804ea6..1192558632 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md @@ -14,7 +14,7 @@ A Workspace is identified by its stable id and canonical directory path, while i The Host's `workspace.create({ path })` adoption route inherits that rule. The Workspace manager, picker, grouping tree, selection, rename, deletion, and Session creation continue to use `WorkspaceId`, so equal labels neither merge records nor redirect an operation. The sidebar hover card exposes each canonical path when the labels need disambiguation. -Explicit naming remains stricter. `workspace.create({ name })` and `workspace.rename` continue to reject a title already registered, as described by [manual Workspace naming](../feature/2026-07-25-session-list-browsing-and-manual-order.md). This prevents a user from deliberately introducing another ambiguous label while accepting collisions imposed by existing directory names. The path-adoption rule supersedes only the title-conflict clauses in the [Workspace product flow](../feature/2026-07-25-workspace-ui-product-flow.md) and [native directory picker](../feature/2026-07-27-native-workspace-directory-picker.md). +Explicit naming remains stricter. `workspace.rename` continues to reject a title already registered, as described by [manual Workspace naming](../feature/2026-07-25-session-list-browsing-and-manual-order.md). This prevents a user from deliberately introducing another ambiguous label while accepting collisions imposed by existing directory names. The path-adoption rule supersedes only the title-conflict clauses in the [Workspace product flow](../feature/2026-07-25-workspace-ui-product-flow.md) and [native directory picker](../feature/2026-07-27-native-workspace-directory-picker.md). The durable schema does not change: Workspace records already store id, path, and title independently, bootstrap can derive equal basenames, and startup validates duplicate paths rather than titles. @@ -30,7 +30,7 @@ Workspace registry and Host API tests create two real directories under differen **Use the full path as every Workspace title.** This removes the collision but makes the primary navigation label unnecessarily long. The full path remains available in the hover detail while the concise basename stays useful. -**Permit collisions from explicit rename and create-by-name operations too.** The registry supports that state, but those operations intentionally ask the user to choose a display name. Retaining their conflict response preserves the existing naming guard without blocking filesystem-selected paths. +**Permit collisions from the explicit rename operation too.** The registry supports that state, but rename intentionally asks the user to choose a display name. Retaining its conflict response preserves the existing naming guard without blocking filesystem-selected paths. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md index 82cfb7d90a..9c1f1ffd22 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md @@ -14,7 +14,7 @@ Workspace 的身份由其稳定 id 和规范目录路径确定,标题则是可 Host 的 `workspace.create({ path })` 接纳入口沿用该规则。Workspace 管理器、选择器、分组树、选择、重命名、删除和 Session 创建仍使用 `WorkspaceId`,因此相同标签既不会合并记录,也不会把操作指向其他记录。需要区分相同标签时,侧边栏悬停详情卡会显示各自的规范路径。 -显式命名仍采用更严格的规则。`workspace.create({ name })` 和 `workspace.rename` 仍会拒绝已注册的标题,具体见[手动 Workspace 命名](../feature/2026-07-25-session-list-browsing-and-manual-order.md)。这既防止用户主动引入另一个难以区分的标签,又允许既有目录名称造成的重名。路径接纳规则仅取代 [Workspace 产品流](../feature/2026-07-25-workspace-ui-product-flow.md)和[原生目录选择器](../feature/2026-07-27-native-workspace-directory-picker.md)中的标题冲突条款。 +显式命名仍采用更严格的规则。`workspace.rename` 仍会拒绝已注册的标题,具体见[手动 Workspace 命名](../feature/2026-07-25-session-list-browsing-and-manual-order.md)。这既防止用户主动引入另一个难以区分的标签,又允许既有目录名称造成的重名。路径接纳规则仅取代 [Workspace 产品流](../feature/2026-07-25-workspace-ui-product-flow.md)和[原生目录选择器](../feature/2026-07-27-native-workspace-directory-picker.md)中的标题冲突条款。 持久化 schema 未变:Workspace 记录本就分别存储 id、path 和 title,引导初始化可以派生出相同的 basename,启动校验检查的是重复路径而非重复标题。 @@ -30,7 +30,7 @@ Workspace 注册表与 Host API 测试会在不同父目录下创建两个末级 **将完整路径用作每个 Workspace 的标题。** 这会消除冲突,却使主导航标签不必要地过长。完整路径仍可在悬停详情中查看,而简洁的 basename 仍有价值。 -**也允许显式重命名和按名称创建操作产生重名。** 注册表支持这种状态,但这些操作本就是明确要求用户选择显示名称。保留冲突响应可维持现有命名防护,同时不阻止从文件系统选取的路径。 +**也允许显式重命名操作产生重名。** 注册表支持这种状态,但该操作本就是明确要求用户选择显示名称。保留冲突响应可维持现有命名防护,同时不阻止从文件系统选取的路径。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml index fe955d125d..c8f5bfdce4 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md -2026-07-25-session-list-browsing-and-manual-order.md: bd04e7f74c8a4540d68e60ad68965e05de76bce9 -2026-07-25-session-list-browsing-and-manual-order.zh.md: 8ec5f71943a68a70f46fbd4c7702e4556b1892ec +2026-07-25-session-list-browsing-and-manual-order.md: 2894542e7b3b720702c764dbae4112b001e2602c +2026-07-25-session-list-browsing-and-manual-order.zh.md: 81c613a6d2ac738a993f82207ef7c3820cd751f0 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md index bd04e7f74c..2894542e7b 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -24,7 +24,7 @@ The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode rend ### workspace.rename -`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the Host's serialized workspace-operation chain (shared with create-by-name, so concurrent explicit naming operations cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Path adoption may derive a title already present because canonical path, not title, owns identity ([decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)). Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. +`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the Host's serialized workspace-operation chain (shared with path adoption and deletion, so concurrent workspace operations cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Path adoption may derive a title already present because canonical path, not title, owns identity ([decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)). Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. ### Manual order: insertSessionBefore replaces activity pinning diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index 8ec5f71943..81c613a6d2 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -24,7 +24,7 @@ group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 ### workspace.rename -`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 Host 的 Workspace 操作串行链内求值(与按名称创建共链,并发的显式命名操作不能穿插出重名或乱序假成功),冲突返回 `workspace-name-conflict`。按路径收编可以派生出已有 title,因为拥有身份的是 canonical path,而不是 title(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md))。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 +`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 Host 的 Workspace 操作串行链内求值(与按路径收编和删除共链,并发的 Workspace 操作不能穿插出重名或乱序假成功),冲突返回 `workspace-name-conflict`。按路径收编可以派生出已有 title,因为拥有身份的是 canonical path,而不是 title(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md))。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 ### 手动排序:insertSessionBefore 取代活动置顶 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index d8232afa44..c12ab62d53 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md -2026-07-25-workspace-ui-product-flow.md: 7a6a41e19d2930fbcbf7ba5fc9e6809d96e23166 -2026-07-25-workspace-ui-product-flow.zh.md: a40f374fd794b11bff0de72cbc822fd638cd267c +2026-07-25-workspace-ui-product-flow.md: 9f241562c2d07801b22619c8e1406984bab22aba +2026-07-25-workspace-ui-product-flow.zh.md: 7fca17d32837deede4fb751ca4317582d796e005 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index 7a6a41e19d..9f241562c2 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -19,13 +19,12 @@ The Host provides the following GUI wiring on the Workspace entity: | RPC | Behavior | | --- | --- | | `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | -| `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict | | `workspace.create({ path })` | Adopts an existing directory by canonical path; basename-derived display titles may repeat | | `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped | | `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | | `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | -`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, including `host/workspace-removed`, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. Registration-deletion ownership and safety are defined in the [Workspace registration deletion Agent Note](2026-07-27-workspace-registration-deletion.md). +The Host stream pushes Workspace and Session deltas, including `host/workspace-removed`, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. Registration-deletion ownership and safety are defined in the [Workspace registration deletion Agent Note](2026-07-27-workspace-registration-deletion.md). A Workspace's `sessionIds` is an ordered candidate index. A membership projection requires both that an id appear in the index and that the corresponding canonicalized `SessionHeader.cwd` equal the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id is filtered out if its header is missing, its cwd is invalid, or its cwd does not match. Two Workspace indexes claiming the same Session is corrupt state and fails loudly. @@ -52,7 +51,7 @@ When no Workspace exists, the page creates a frontend Workspace object named `wo Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. -A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); explicit create-by-name and rename operations retain their duplicate-title checks. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. +A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); the explicit rename operation retains its duplicate-title check. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. ### First send and recovery @@ -108,7 +107,7 @@ The Sidebar and conversation empty hero receive standardized actions through slo - Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd. - The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. - A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. -- The UI and Host admit distinct same-basename directories as separate Workspaces, while explicit create-by-name and rename operations reject duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. +- The UI and Host admit distinct same-basename directories as separate Workspaces, while the explicit rename operation rejects duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. - Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback. - Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index a40f374fd7..7fca17d328 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -19,13 +19,12 @@ Host 在 Workspace entity 上提供以下 GUI 接线: | RPC | 行为 | | --- | --- | | `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | -| `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace;显示名冲突时失败 | | `workspace.create({ path })` | 按 canonical path 收编已有目录;由 basename 派生的显示名可以重复 | | `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped | | `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | | `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | -`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。 +Host stream 推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。 Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并 fail loud。 @@ -52,7 +51,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 -新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的按名称创建和重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 +新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 ### 首次发送与恢复 @@ -108,7 +107,7 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe - Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。 - 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 - 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 -- UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的按名称创建和重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 +- UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 - 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。 - keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml index 691511d76b..e1a166fd0c 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md -2026-07-31-one-route-to-add-a-workspace.md: 853d641e0a2c0044ee7bfd6ed42bcc3763520192 -2026-07-31-one-route-to-add-a-workspace.zh.md: 6a2884d75138ddc241276131f7ff85080fc5d794 +2026-07-31-one-route-to-add-a-workspace.md: d0a1a820a8eb0a47245d99635b5e1e0448d7eca5 +2026-07-31-one-route-to-add-a-workspace.zh.md: 3486fcdae24a77d3ba82234355ba52d5f43e22d0 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md index 853d641e0a..d0a1a820a8 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md @@ -25,7 +25,7 @@ The direct-open path carries the busy rule the menu entry states: while a pick i `WorkspaceCreateFlow` is now `WorkspacePickFlow` and its `createOnly` prop is `addOnly`; the injected `createWorkspace` narrows from `{ name } | { path }` to `{ path }`. -## Wire and CLI residue +## Wire and CLI follow-up (shipped) Deleted in the follow-up change this section used to scope: `workspace.create` accepts only `{ path }` (the `name` member left the wire schema and `WorkspaceApi`), the gateway lost its `workspaceRoot` config and default, the client seam narrowed to the path spelling (`WorkspaceCreateInput`, `WorkspacesService.create`, `intentName`), and the `dsh web --workspace-root` flag is gone together with its `apps/cli` reference lines. `workspace-name-conflict` remains on the wire as `workspace.rename`'s duplicate-title error. @@ -45,7 +45,7 @@ Deleted in the follow-up change this section used to scope: `workspace.create` a **Keep the menu shell for entries we might add later (clone a repo, remote directory).** Rejected under "require a current owner and need": no such entry exists, and restoring a menu when one arrives is a smaller change than shipping an empty frame now. -**Delete the wire's create-by-name branch in the same change.** Rejected for this PR: it is backend/CLI surface with a different reviewer and a wider test fallout, and the urgent decision is the UI. See the residue section — it is marked, not forgotten. +**Delete the wire's create-by-name branch in the same change.** Rejected for the UI PR: it was backend/CLI surface with a different reviewer and a wider test fallout, and the urgent decision was the UI. The deletion shipped as its own follow-up change; the follow-up section above records what it removed. **Register the workspace through the host in the e2e scaffold instead of driving the dialog.** Rejected: it would have decoupled all 15 scenarios from the picker, so nothing in the lane would prove the surviving route reaches a live composer. Every scenario now walks the real dialog to adopt its directory; only the create-a-folder half is concentrated in one scenario, because repeating it everywhere makes the shared helper non-idempotent for no extra signal. diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md index 6a2884d751..3486fcdae2 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md @@ -25,7 +25,7 @@ Status: implemented `WorkspaceCreateFlow` 现更名为 `WorkspacePickFlow`,其 `createOnly` prop 更名为 `addOnly`;注入的 `createWorkspace` 从 `{ name } | { path }` 收窄为 `{ path }`。 -## Wire and CLI residue +## Wire and CLI follow-up (shipped) 本节曾划定的后续删除已经落地:`workspace.create` 只接受 `{ path }`(`name` 成员已从 wire schema 与 `WorkspaceApi` 移除),网关失去了 `workspaceRoot` 配置及其默认值,客户端 seam 收窄为 path 写法(`WorkspaceCreateInput`、`WorkspacesService.create`、`intentName`),`dsh web --workspace-root` flag 连同其 `apps/cli` reference 文档行一并删除。`workspace-name-conflict` 仍留在 wire 上,作为 `workspace.rename` 的重名错误。 @@ -45,7 +45,7 @@ Status: implemented **为将来可能新增的入口(克隆仓库、远程目录)保留菜单壳。** 否决,依据"require a current owner and need":这样的入口目前并不存在,而等它到来时再恢复菜单,比现在就发一个空壳的改动更小。 -**在同一改动中删除 wire 的按名称创建分支。** 本 PR 否决:那是 backend/CLI 面,reviewer 不同、测试波及面更广,而紧急的决定是 UI。见 residue 一节——它是被标记了,不是被遗忘了。 +**在同一改动中删除 wire 的按名称创建分支。** UI PR 否决:那是 backend/CLI 面,reviewer 不同、测试波及面更广,而当时紧急的决定是 UI。删除随后作为独立的后续改动落地,上文 follow-up 一节记录了它移除的内容。 **在 e2e scaffold 中经 host 注册 workspace,而不驱动对话框。** 否决:那会让全部 15 个场景与选择器解耦,整条 lane 将无法证明幸存的这条路径能走到可用的 composer。现在每个场景都会走真实对话框来接纳自己的目录;只有"新建文件夹"那一半集中在一个场景里,因为处处重复只会让共享辅助函数失去幂等性,却换不来额外信号。 diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 5b9824fcde..6bb6e2e4ab 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -535,7 +535,7 @@ describe('createFixtureApi', () => { expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } }) }) - it('workspace.create by name mints a new entity and pushes host/workspace-changed', async () => { + it('workspace.create on a fresh path mints a new entity and pushes host/workspace-changed', async () => { const api = createFixtureApi() const abort = new AbortController() const seen: HostFrame[] = [] diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index b548b36702..15b2e47989 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -252,8 +252,7 @@ describe('host.openPath', () => { describe('workspace.create', () => { it('serializes concurrent creates of one path into a single registration', async () => { const { api, root } = await harness() - const target = join(root, 'alpha') - mkdirSync(target) + const target = stageDir(root, 'alpha') const responses = await Promise.all([ api.workspace.create(request({ path: target })), api.workspace.create(request({ path: target })), @@ -269,8 +268,7 @@ describe('workspace.create', () => { it('adopts only existing directories', async () => { const { api, root } = await harness() - const existing = join(root, 'existing') - mkdirSync(existing) + const existing = stageDir(root, 'existing') const first = expectOk(await api.workspace.create(request({ path: existing }))) const repeated = expectOk(await api.workspace.create(request({ path: existing }))) expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 4639b5c5ee..90cee6e8cd 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -327,6 +327,8 @@ describe('workspace domain schemas', () => { it('create requires a path', () => { expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p') expect(() => workspaceCreateRequestSchema.parse({})).toThrow() + // The retired create-by-name spelling stays a clean schema rejection. + expect(() => workspaceCreateRequestSchema.parse({ name: 'n' })).toThrow() expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) }) diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 71401862f2..904959c8ec 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -139,6 +139,11 @@ export class WorkspaceRegistry extends Service { * @param title - Display title used only when a new record is created. * @returns the existing or newly durable workspace. */ + // TODO: `title` lost its last production caller when the gateway's + // create-by-name branch was deleted + // (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md); + // drop the parameter with its @param clause and the `create(path, title?)` + // lines in this package's README pair. async create(path: string, title?: string): Promise { const canonical = await realpathNormalize(path) if (!(await stat(canonical)).isDirectory()) { diff --git a/scripts/hero-composer-dom-continuity.mjs b/scripts/hero-composer-dom-continuity.mjs deleted file mode 100644 index ef39052b1b..0000000000 --- a/scripts/hero-composer-dom-continuity.mjs +++ /dev/null @@ -1,78 +0,0 @@ -// Regression drive for the unified hero composer (0729-0357-hero-unify): -// cold start with zero workspaces -> create a workspace -> type. Asserts the -// composer textarea is the SAME DOM node across the disabled->live flip (a -// remount drops the __heroMark marker property) — the session-maybe -// composer.bar contract. -// -// Prereqs: `pnpm run build`, then a fresh server against empty state: -// rm -rf .storages && DSH_HOME=$(mktemp -d) node --experimental-transform-types \ -// --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web --port 44285 \ -// --workspace-root $(mktemp -d) -// Run: node scripts/hero-composer-dom-continuity.mjs -// (BASE_URL overrides the target; screenshots land in .artifacts/.) -import { createRequire } from 'node:module' - -// playwright is a devDependency of apps/web only — resolve through its tree. -const require = createRequire(new URL('../apps/web/package.json', import.meta.url)) -const { chromium } = require('playwright') - -const BASE = process.env.BASE_URL ?? 'http://127.0.0.1:44285' -const SHOTS = new URL('../.artifacts/screenshots/0729-0357-hero-unify/', import.meta.url).pathname - -const browser = await chromium.launch() -const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }) -page.on('console', msg => { if (msg.type() === 'error') console.log('[console.error]', msg.text()) }) -page.on('pageerror', err => { console.log('[pageerror]', err.message) }) - -await page.goto(BASE) -await page.waitForSelector('textarea', { timeout: 20000 }) -await page.screenshot({ path: SHOTS + '01-cold-start.png' }) - -const initial = await page.evaluate(() => { - const boxes = [...document.querySelectorAll('textarea')] - boxes.forEach((b, i) => { b.__heroMark = 'alive-' + i }) - return boxes.map(b => ({ disabled: b.disabled, placeholder: b.placeholder })) -}) -console.log('cold-start textareas:', JSON.stringify(initial)) - -// Open the picker and create a workspace by name (typed-input flow). The name -// must be unique per registry; keystrokes go through pressSequentially so the -// dialog's React onChange enables the submit button. -await page.getByRole('button', { name: 'Choose workspace' }).click() -await page.getByText('Create a new workspace').click() -await page.screenshot({ path: SHOTS + '03-create-form.png' }) -const nameBox = page.getByPlaceholder('Workspace name') -await nameBox.click() -const wsName = 'proj-' + Date.now().toString(36) -await nameBox.pressSequentially(wsName, { delay: 30 }) -await page.locator('button:text-is("Create workspace")').click() - -// Wait for the composer to go live (placeholder flips, textarea enabled). -await page.waitForFunction(() => { - const box = document.querySelector('textarea') - return box !== null && !box.disabled -}, { timeout: 20000 }) -await page.screenshot({ path: SHOTS + '04-live.png' }) - -const after = await page.evaluate(() => { - const boxes = [...document.querySelectorAll('textarea')] - return boxes.map(b => ({ - mark: b.__heroMark ?? 'REMOUNTED', - disabled: b.disabled, - placeholder: b.placeholder, - })) -}) -console.log('post-pick textareas:', JSON.stringify(after)) - -// Type into the live composer. -await page.locator('textarea').first().fill('hello from acceptance run') -const typed = await page.evaluate(() => document.querySelector('textarea')?.value) -console.log('typed value:', JSON.stringify(typed)) -await page.screenshot({ path: SHOTS + '05-typed.png' }) - -const survived = after.length === 1 && after[0].mark === 'alive-0' -console.log(survived - ? 'DOM-CONTINUITY: PASS (same textarea node across cold-start -> live)' - : 'DOM-CONTINUITY: FAIL ' + JSON.stringify(after)) -await browser.close() -process.exit(survived && typed === 'hello from acceptance run' ? 0 : 1) From b7fe3de8f184cdcec5ab20eb7205660368e4038a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 7 Aug 2026 14:54:07 +0800 Subject: [PATCH 042/229] chore(knip): drop the stale playwright ignore for the deleted hero script The root-scripts workspace ignore existed only for scripts/hero-composer-dom-continuity.mjs; apps/web declares its own playwright dependency for the e2e lane. --- knip.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/knip.json b/knip.json index 6dc4b56dcd..fbf456d3b9 100644 --- a/knip.json +++ b/knip.json @@ -27,9 +27,6 @@ "scripts/**/*.ts", "scripts/**/*.mjs", "scripts/**/*.cjs" - ], - "ignoreDependencies": [ - "playwright" ] }, "examples": { From 0833b29f25378eaad903021a77b6d1414136ad3a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 16:43:59 +0800 Subject: [PATCH 043/229] fix(web): persist general preferences in host settings --- ...07-30-client-locale-full-rollout.i18n.yaml | 4 +- .../2026-07-30-client-locale-full-rollout.md | 2 +- ...026-07-30-client-locale-full-rollout.zh.md | 2 +- ...-06-host-backed-web-preferences.i18n.yaml} | 6 +- .../2026-08-06-host-backed-web-preferences.md | 41 +++ ...26-08-06-host-backed-web-preferences.zh.md | 41 +++ ...-08-06-host-backed-web-theme-preference.md | 39 --- ...-06-host-backed-web-theme-preference.zh.md | 39 --- ...026-07-30-web-queue-steer-action.i18n.yaml | 4 +- .../2026-07-30-web-queue-steer-action.md | 4 +- .../2026-07-30-web-queue-steer-action.zh.md | 4 +- ...1-browser-derived-initial-locale.i18n.yaml | 4 +- ...26-07-31-browser-derived-initial-locale.md | 8 +- ...07-31-browser-derived-initial-locale.zh.md | 8 +- ...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 +- apps/web/tests/assembled-boot.ts | 49 +++- apps/web/tests/settings-chrome.e2e.ts | 90 +++++-- apps/web/tests/support.ts | 13 +- docs/event-producer-consumer.md | 4 +- packages/client/locale/README.i18n.yaml | 4 +- packages/client/locale/README.md | 2 +- packages/client/locale/README.zh.md | 2 +- packages/client/locale/package.json | 8 +- packages/client/locale/src/client/index.ts | 89 ++++--- packages/client/locale/src/index.ts | 35 ++- packages/client/locale/src/locale-settings.ts | 22 ++ packages/client/locale/tests/apply.spec.ts | 54 +++- packages/client/locale/tests/host.spec.ts | 30 +++ .../client/locale/tests/invariant.spec.ts | 8 +- packages/client/locale/tests/locale.spec.ts | 35 +-- packages/client/locale/tsconfig.json | 3 + 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/settings-preference.ts | 160 ++++++++++++ .../runtime/tests/settings-preference.spec.ts | 237 ++++++++++++++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- packages/client/ui-conversation/package.json | 9 +- .../ui-conversation/src/client/apply.ts | 14 +- .../client/contract/composer-submission.ts | 9 +- .../src/client/input/submission-policy.ts | 58 ++--- packages/client/ui-conversation/src/index.ts | 37 ++- .../src/submission-settings.ts | 25 ++ .../tests/apply-inject.spec.tsx | 1 + .../tests/assembly-surfaces.spec.tsx | 4 + .../ui-conversation/tests/chat-apply.spec.tsx | 1 + .../tests/chat-code-subcalls.spec.tsx | 1 + .../tests/chat-toolview-slot.spec.tsx | 2 + .../tests/coverage-tails.spec.tsx | 7 +- .../client/ui-conversation/tests/host.spec.ts | 37 +++ .../tests/submission-policy.spec.ts | 50 ++-- packages/client/ui-conversation/tsconfig.json | 6 + .../ui-subagent/tests/browser-plugin.spec.ts | 8 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- packages/client/ui-theme/package.json | 1 - packages/client/ui-theme/src/client/index.ts | 41 +-- .../ui-theme/src/client/theme-settings.ts | 100 -------- packages/client/ui-theme/src/index.ts | 6 +- .../client/ui-theme/src/theme-settings.ts | 7 +- packages/client/ui-theme/tests/apply.spec.ts | 32 ++- .../client/ui-theme/tests/invariant.spec.ts | 4 +- .../ui-theme/tests/theme-settings.spec.ts | 149 ----------- packages/client/ui-theme/tests/theme.spec.ts | 3 +- packages/client/ui-theme/tsconfig.json | 3 - packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 2 +- .../apiproxy/tests/api-proxy-config.spec.ts | 24 +- pnpm-lock.yaml | 25 +- vitest.config.ts | 9 +- 78 files changed, 1153 insertions(+), 615 deletions(-) rename .agents/notes/implemented/bug-fix/{2026-08-06-host-backed-web-theme-preference.i18n.yaml => 2026-08-06-host-backed-web-preferences.i18n.yaml} (55%) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md create mode 100644 packages/client/locale/src/locale-settings.ts create mode 100644 packages/client/locale/tests/host.spec.ts create mode 100644 packages/client/runtime/src/client/settings-preference.ts create mode 100644 packages/client/runtime/tests/settings-preference.spec.ts create mode 100644 packages/client/ui-conversation/src/submission-settings.ts create mode 100644 packages/client/ui-conversation/tests/host.spec.ts delete mode 100644 packages/client/ui-theme/src/client/theme-settings.ts delete mode 100644 packages/client/ui-theme/tests/theme-settings.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index 2efe235e28..f8e6600a47 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: 09baf5876029295f7a80b6a0fe6a6395d98f406c -2026-07-30-client-locale-full-rollout.zh.md: 806916aea15a21fd24fdfc4654976b3c4577a675 +2026-07-30-client-locale-full-rollout.md: 0faf4e0424e037b59b24d32f7fa987ac36497691 +2026-07-30-client-locale-full-rollout.zh.md: 895a2b4e87d2734bad27724f56b3205d81ac755e diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index 09baf58760..0faf4e0424 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -25,7 +25,7 @@ After the typed locale standard seat landed (`locale:` on register → framework **Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure. -**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (pins `dsh.locale=en` before boot) and the built-boot snapshot pins the same — goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the initial locale follows `navigator` ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). +**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (an `en-US` browser) and the built-boot snapshot pins the same navigator language—goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the provisional locale follows `navigator` before an explicit Host preference arrives ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). The "apply layer subscribes to `locale/change` and re-registers for fresh labels" mechanism in the [settings/locale/theme layering note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) is superseded by this decision (thunk + revision lifecycle). diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 806916aea1..895a2b4e87 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -25,7 +25,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` **派生层保持纯函数,本地化只在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板;blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。 -**测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一 `newEnglishPage`(boot 前钉 `dsh.locale=en`),built-boot snapshot 同样钉 en——golden 对语言迁移免疫;settings 语言切换用例绕开该 helper 并开启 `zh-CN` 浏览器,因为初始 locale 跟随 `navigator`([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md))。 +**测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一通过 `newEnglishPage`(`en-US` 浏览器)打开,built-boot snapshot 同样固定 navigator 语言:golden 因而不受语言迁移影响。settings 语言切换用例绕开该 helper 并开启 `zh-CN` 浏览器,因为在显式 Host 偏好到达前,暂定 locale 会跟随 `navigator`([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md))。 [settings/locale/theme 分层 Note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) 中"apply 层订阅 `locale/change` 重注册刷新 label"的机制已被本决定取代(thunk + revision 生命周期)。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml similarity index 55% rename from .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml index 7e804aad59..13dd2d5672 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md -2026-08-06-host-backed-web-theme-preference.md: 129132586b0d0ccfdb5b32fdaa1f7178a7176db7 -2026-08-06-host-backed-web-theme-preference.zh.md: 0c2dafff3fc3cec49a2261e31a61ecf99a10f126 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md +2026-08-06-host-backed-web-preferences.md: ee1c0aea360eb1a4b34eadc86c5c3091abc6663a +2026-08-06-host-backed-web-preferences.zh.md: 376e670f9af39f43783a1447498ca2d4c65a49cd diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md new file mode 100644 index 0000000000..ee1c0aea36 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md @@ -0,0 +1,41 @@ +# Agent Note: Persist Web user preferences through Host settings + +Status: implemented + +English | [中文](2026-08-06-host-backed-web-preferences.zh.md) + +## Problem + +The Web Appearance, Language, and busy-Enter preferences lived in browser `localStorage`. Browser storage is scoped to an origin, so reopening `dsh web` on another port selected a different partition and lost choices even though both processes used the same DSH home. These are user-level product preferences; session selection, drafts, disclosure state, and other transient browser state remain page-local. + +The first theme implementation moved only Appearance to Host settings but awaited its initial RPC before providing `ThemeService`. A slow or unavailable settings request therefore suspended the assembled page. It also subscribed after the read, could miss an invalidation in that window, did not carry namespace revisions on writes, and allowed queued writes from a disposed plugin to reach the Host. + +## Decision + +The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy explicitly exposes all three namespaces beside the other Web settings; registration alone never crosses that configuration boundary. + +The client runtime provides one `bindSettingsPreference` lifecycle for scalar preferences. It installs `settings/changed` and `connection/reset` listeners before starting a background initial read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap. Domain services publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then accept a validated Host value without writing it back. + +User changes update the live service synchronously and queue a `settings.mutate` path operation. The controller serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence. + +Remote browsers cannot call the loopback-only configuration API, so their preferences remain process-local. Dynamic third-party theme ids remain in-process extensions outside the built-in Host schema; removing one resets the live registry without replacing the last durable built-in preference. + +## Alternatives considered + +**Keep `localStorage` and copy values between ports.** One origin cannot enumerate another origin's storage, and a Host relay would recreate the settings service around a browser-specific format. + +**Mirror Host settings into `localStorage`.** A second authority requires boot and invalidation conflict rules while retaining the partition that caused the defect. The Host document is the sole durable source. + +**Await the initial read to avoid a provisional render.** Configuration availability is not a prerequisite for drawing the page. A background read may cause one live convergence, but it keeps failure isolated and preserves the existing browser/system/default fallbacks. + +**Give every domain its own settings controller.** The concurrency, revision, failure, invalidation, and disposal rules are identical; copying them already produced lifecycle drift in the theme implementation. Domain-owned schemas and decoders keep product policy out of the shared runtime. + +**Move every `localStorage` entry into settings.** Current session, drafts, panel disclosure, trajectory display state, and similar entries are browser-instance state rather than user configuration. Promoting them would synchronize transient navigation state across tabs and ports without a product contract. + +## Consequences + +Appearance, Language, and busy-Enter choices follow the DSH user home across reloads, ports, and loopback origins. Direct edits to `settings.yaml` converge through the existing invalidation stream, while legacy `dsh.theme`, `dsh.locale`, and `dsh.conversation.busyEnter` entries are neither read nor written. + +Boot may briefly show the domain default before the background read settles. A transient read failure keeps that default or the last good in-process value; reconnect retries. A write rejection can visibly restore the durable preference after the immediate local change. + +Focused unit coverage pins schema registration, listener-before-read ordering, nonblocking activation, revisioned ordered writes, stale-response containment, failure recovery, disposal quiescence, and remote memory mode. The keyless Web settings scenario writes all three preferences through the UI, verifies the YAML document and empty legacy storage, reloads, and boots another Host on a distinct port against the same DSH home. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md new file mode 100644 index 0000000000..376e670f9a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 通过 Host settings 持久化 Web 用户偏好 + +Status: implemented + +[English](2026-08-06-host-backed-web-preferences.md) | 中文 + +## 问题 + +Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `localStorage` 中。浏览器存储以 origin 为作用域,因此换一个端口重新打开 `dsh web` 会选中另一个存储分区并丢失选择,即使两个进程使用同一个 DSH home。这些是用户级产品偏好;会话选择、草稿、折叠展开状态和其他瞬态浏览器状态仍保留在页面内。 + +第一版主题实现只把 Appearance 移入 Host settings,但会在提供 `ThemeService` 之前等待初始 RPC。缓慢或不可用的 settings 请求因而会挂起组装后的页面。该实现还在读取后才建立订阅,可能错过此窗口内的失效通知;它写入时不携带 namespace revision,并且允许已释放插件所排队的写入到达 Host。 + +## 决策 + +各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会显式暴露这三个 namespace,与其他 Web settings 并列;仅注册它们,绝不会跨越该配置边界。 + +客户端运行时为标量偏好提供一份 `bindSettingsPreference` 生命周期。它在开始后台初始读取之前安装 `settings/changed` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档。领域服务会立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后接纳已校验的 Host 值,但不将其写回。 + +用户变更会同步更新实时服务,并将一项 `settings.mutate` 路径操作排入队列。控制器会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,控制器会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。 + +远程浏览器无法调用仅限回环请求的配置 API,因此其偏好仅保留在进程内。动态第三方主题 id 仍是内置 Host schema 之外的进程内扩展;移除其中一个会重置实时注册表,但不会替换上一个持久化的内置偏好。 + +## 曾考虑的替代方案 + +**保留 `localStorage`,并在不同端口间复制值。** 一个 origin 无法枚举另一个 origin 的存储,而 Host 中继会围绕浏览器特有格式重新实现一套 settings 服务。 + +**将 Host settings 镜像到 `localStorage`。** 第二个权威来源会要求另外定义启动与失效时的冲突规则,同时依然保留造成该缺陷的分区。Host settings 文档是唯一的持久化真源。 + +**等待初始读取,以避免暂定渲染。** 绘制页面不以配置可用为前置条件。后台读取可能引发一次实时收敛,但它会隔离失败,并保留既有的浏览器/系统/默认回落路径。 + +**让每个领域拥有自己的 settings 控制器。** 并发、revision、失败、失效与释放规则完全一致;此前的主题实现已因复制这些规则产生生命周期漂移。由领域持有 schema 和解码器,可以避免把产品政策放入共享运行时。 + +**把每个 `localStorage` 条目都移入 settings。** 当前会话、草稿、面板展开状态、trajectory 显示状态和类似条目属于浏览器实例状态,而非用户配置。将它们提升为设置,会在没有产品契约的情况下,跨标签页和端口同步短暂导航状态。 + +## 后果 + +Appearance、Language 和繁忙态 Enter 选择会跟随 DSH 用户 home,跨越重新加载、端口与回环 origin。直接编辑 `settings.yaml` 所产生的变更会通过现有失效流收敛,而旧的 `dsh.theme`、`dsh.locale` 和 `dsh.conversation.busyEnter` 条目既不会被读取,也不会被写入。 + +启动时可能会在后台读取结算前短暂显示领域默认值。短暂的读取失败会保留该默认值或上一个正确的进程内值;重连时会重试。写入被拒时,界面可能会在本地值立即变化后明显恢复为持久化偏好。 + +聚焦的单元测试覆盖 schema 注册、先监听后读取的顺序、非阻塞激活、携带 revision 的有序写入、陈旧响应隔离、故障恢复、释放时完全停稳,以及远程端仅内存模式。无密钥 Web settings 场景通过 UI 写入全部三项偏好,校验 YAML 文档并确认旧 `localStorage` 为空,重新加载,再使用同一个 DSH home 在不同端口上启动另一个 Host。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md deleted file mode 100644 index 129132586b..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Persist the Web theme through Host settings - -Status: implemented - -English | [中文](2026-08-06-host-backed-web-theme-preference.zh.md) - -## Problem - -The Web theme preference lived in browser `localStorage`. Browser storage is scoped to an origin, so reopening `dsh web` on another port selected a different storage partition and returned to the default system theme even though both processes used the same DSH home. - -The theme is a user-level product preference rather than page-local state. DSH already has a user-settings service with a file-backed provider, a loopback-only configuration wire, and invalidation frames for external edits and other tabs. - -## Decision - -The `@deepseek-ai/dsh-client-ui-theme` Host half registers `ui-theme.preference` with the built-in `light`, `dark`, and `system` values and a `system` default. The local settings provider stores an override in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. - -The loopback client loads that namespace before it provides `ThemeService`, so the initial presenter snapshot reflects the durable preference without relying on an origin cache. `ThemeService.setTheme` still changes the live snapshot synchronously; its persistence callback sends a `settings.mutate` path operation. The controller serializes rapid selections in gesture order, ignores stale settlements, reloads after a rejected latest write, and refetches on `settings/changed` or `connection/reset`. - -The API proxy explicitly exposes `ui-theme` beside `permission` and `ui-onboarding`. Registration alone remains insufficient to cross the configuration boundary. Remote browsers cannot call the privileged settings API and retain only a process-local selection. - -Only the built-in product preferences cross the Host schema. Third-party registered theme ids remain an in-process extension because the Host cannot validate a browser plugin's dynamic registry during startup. - -## Alternatives considered - -**Keep `localStorage` and copy values between ports.** One origin cannot enumerate another origin's storage, and a Host-side relay would recreate a settings service around a browser-specific format. - -**Use a cookie without an explicit port.** Cookies would couple preference durability to the served hostname, still split localhost aliases, and introduce HTTP state outside the user-settings ownership model. - -**Mirror Host settings into `localStorage`.** A second authority creates boot and invalidation conflict rules while retaining the origin partition that caused the defect. The Host document is the sole durable source. - -**Expose every registered settings namespace.** Automatic exposure would let an unrelated plugin become remotely configurable by registering with the general settings seam. The API proxy keeps an explicit allowlist. - -## Consequences - -Theme selections follow the DSH user home across reloads, ports, and loopback origins, and direct edits to `settings.yaml` converge through the existing invalidation stream. The settings document contains a readable section such as `ui-theme: { preference: dark }`; no theme value is written to `localStorage`. - -Startup performs one loopback settings read before publishing the theme service. A transient read failure keeps the system default or last good in-process value and reconnect can retry. A write rejection can visibly restore the durable preference after the immediate theme change. - -Unit coverage pins schema registration, ordered writes, stale-response containment, failure recovery, invalidation refresh, and remote memory mode. The real Web settings scenario writes dark through the UI, verifies the YAML document, reloads, and boots a second Host on another port against the same DSH home with an empty theme `localStorage` partition. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md deleted file mode 100644 index 0c2dafff3f..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: 通过 Host settings 持久化 Web 主题 - -Status: implemented - -[English](2026-08-06-host-backed-web-theme-preference.md) | 中文 - -## 问题 - -Web 主题偏好原本存在浏览器 `localStorage` 中。浏览器存储以 origin 为作用域,因此换一个端口重新打开 `dsh web` 会选中另一个存储分区,并回到默认的系统主题,即使两个进程使用同一个 DSH home。 - -主题是用户级产品偏好,而非页面局部状态。DSH 已有用户 settings 服务及其基于文件的提供方,也已有仅限回环请求的配置协议,并为外部编辑和其他标签页提供失效帧。 - -## 决策 - -`@deepseek-ai/dsh-client-ui-theme` 的 Host half 注册 `ui-theme.preference`,可取内置值 `light`、`dark` 与 `system`,默认值为 `system`。本地 settings 提供方将覆盖值存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。 - -来自回环地址的客户端会在提供 `ThemeService` 之前加载该 namespace,因此初始呈现器快照会反映持久化偏好,无需依赖按 origin 划分的缓存。`ThemeService.setTheme` 仍会同步更新实时快照;它的持久化回调会发送一项 `settings.mutate` 路径操作。控制器按操作顺序串行处理连续快速选择,忽略陈旧操作的结算结果,在最新写入被拒后重新加载持久化值,并在发生 `settings/changed` 或 `connection/reset` 时重新拉取。 - -API 代理会显式暴露 `ui-theme`,与 `permission` 和 `ui-onboarding` 并列。仅注册该设置,仍不足以跨越配置边界。远程浏览器无法调用特权 settings API,其主题选择仅保留在进程内。 - -只有产品内置偏好才会跨越 Host schema。第三方注册的主题 id 仍是进程内扩展,因为 Host 无法在启动期间校验浏览器插件的动态注册表。 - -## 曾考虑的替代方案 - -**保留 `localStorage`,并在不同端口间复制值。** 一个 origin 无法枚举另一个 origin 的存储,而 Host 侧中继会围绕浏览器特有格式重新实现一套 settings 服务。 - -**使用不显式包含端口的 cookie。** Cookie 会将偏好的持久性与提供服务的 hostname 耦合,localhost 的不同 alias 仍会各自分区,还会在用户 settings 的所有权模型之外引入 HTTP 状态。 - -**将 Host settings 镜像到 `localStorage`。** 第二个权威来源会导致启动与失效时需要另外定义冲突规则,同时依然保留造成该缺陷的 origin 分区。Host 侧 settings 文档是唯一的持久化真源。 - -**暴露所有已注册的 settings namespace。** 自动暴露会让与本功能无关的插件仅凭向通用 settings seam 注册,就成为可远程配置的插件。API 代理保留一份显式 allowlist。 - -## 后果 - -主题选择会跟随 DSH 用户 home,跨越重新加载、端口与回环 origin;直接编辑 `settings.yaml` 所产生的变更也会通过现有失效流收敛。settings 文档包含形如 `ui-theme: { preference: dark }` 的可读分节;不会向 `localStorage` 写入主题值。 - -启动时会在发布主题服务之前执行一次回环 settings 读取。短暂的读取失败会保留系统默认值或上一个正确的进程内值,并可在重连时重试。写入被拒时,界面可能会在主题立即变化后明显恢复为持久化偏好。 - -单元测试覆盖 schema 注册、有序写入、陈旧响应隔离、故障恢复、失效刷新与远程端仅内存模式。真实 Web settings 场景通过 UI 写入 dark,校验 YAML 文档,重新加载,再使用同一个 DSH home 在另一个端口上启动第二个 Host,此时主题 `localStorage` 分区为空。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml index 624da25dc2..104f49af8a 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md -2026-07-30-web-queue-steer-action.md: b04095b81f499982c8680a2d3627d8e98a70d8ac -2026-07-30-web-queue-steer-action.zh.md: b04902b8a8a0d727b01aa6ba5562e12cc5d36c92 +2026-07-30-web-queue-steer-action.md: 2718c5b3cc95f1ab02db80230ba158d9b5c3b4e6 +2026-07-30-web-queue-steer-action.zh.md: 2abc4747ca85d7059dd8bdd86f4d7c5314f41f24 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md index b04095b81f..2718c5b3cc 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md @@ -20,7 +20,7 @@ Activating the action requests strict current-turn steering for that exact `Inbo The running bit is only an interaction hint. AgentLoop's `acceptsNextStep` value is authoritative at the synchronous mutation boundary. If that window has closed, the operation leaves the Queue occurrence unchanged and returns a typed `steer-unavailable` error, after which the original waking occurrence proceeds through Queue. If the driver already claimed the occurrence, it returns the existing `queue-item-not-found` error and independent-turn delivery is already underway. The UI treats both races as converged Queue delivery without a failure notice; transport and unknown errors still surface. -The composer uses a separate best-effort contract for newly typed input. While the addressed session is idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, a General Settings preference assigns plain Enter to Queue (the default) or Steer, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter inserts a newline. An addressed subagent keeps both gestures on its Queue-only continuation transport. The browser persists the preference, and it affects only the steer-capable busy-state gesture pair. If a direct composer Steer misses the current next-step window, AgentLoop automatically admits it as the next waking Queue turn and the Web does not report a failure. +The composer uses a separate best-effort contract for newly typed input. While the addressed session is idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, a General Settings preference assigns plain Enter to Queue (the default) or Steer, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter inserts a newline. An addressed subagent keeps both gestures on its Queue-only continuation transport. The Host settings document persists the preference across Web origins sharing one DSH home, and it affects only the steer-capable busy-state gesture pair. If a direct composer Steer misses the current next-step window, AgentLoop automatically admits it as the next waking Queue turn and the Web does not report a failure. ### Agent and lifecycle boundary @@ -38,7 +38,7 @@ The Host's existing `queuedMirror` remains the sole transient inbox authority. I When AgentLoop claims pending steering, it emits `agent/inbox/dequeue` immediately before synchronously appending the durable `user/message`. The Host retires that steering row on the following microtask, allowing the durable session event to enter the linear mux stream first. On the accepted live event, the client Session retires the first matching current steering occurrence before publishing its snapshot; history replay does not consume a later occurrence that reused the same `MessageId`. ChatView therefore renders one authority at a time without scanning durable history, and the durable projection restores the clock, Copy, and Fork against its logged event time and sequence. An append failure still retires the claimed row. -The existing `session.prompt(mode: 'steer')` contract remains best-effort for new primary-session input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser-local submission policy owns the persisted busy-Enter preference and resolves plain versus accelerated Enter as complementary gestures only for steer-capable sessions; the Settings row and InputBar share that policy without duplicating storage or delivery-window authority. Only the Queue row action is strict, because either negative result converges through the original Queue occurrence. +The existing `session.prompt(mode: 'steer')` contract remains best-effort for new primary-session input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser submission policy owns the live busy-Enter preference while the Host settings service owns durability; the policy resolves plain versus accelerated Enter as complementary gestures only for steer-capable sessions, and the Settings row and InputBar share it without duplicating storage or delivery-window authority. Only the Queue row action is strict, because either negative result converges through the original Queue occurrence. ### Verification diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md index b04902b8a8..2abc4747ca 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md @@ -20,7 +20,7 @@ Web composer 原本会在 agent 运行期间把所有 Enter 提交作为 Queue running 标志位只用于提示交互状态。在同步变更边界上,AgentLoop 的 `acceptsNextStep` 值才是权威依据。如果该窗口已经关闭,操作会保持 Queue 单次入队项不变并返回类型化的 `steer-unavailable` 错误,随后原唤醒单次入队项会经 Queue 继续执行。如果驱动器已经认领该项,则返回现有的 `queue-item-not-found` 错误,且独立轮次投递已经开始。UI 会把两种竞态都视为已收敛的 Queue 投递,不显示失败通知;传输和未知错误仍会显示。 -Composer 对新输入采用另一套尽力而为契约。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。已寻址 subagent 会让这两个手势都使用其仅支持 Queue 的继续执行传输。浏览器会持久化该偏好,并且它只影响支持 steering 的繁忙态手势对。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。 +Composer 对新输入采用另一套尽力而为契约。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。已寻址 subagent 会让这两个手势都使用其仅支持 Queue 的继续执行传输。Host settings 文档会在共享同一 DSH home 的 Web origin 之间持久化该偏好,并且它只影响支持 steering 的繁忙态手势对。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。 ### Agent 与生命周期边界 @@ -38,7 +38,7 @@ Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/q AgentLoop 认领待处理 steering 时,会在同步追加持久 `user/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久 session 事件先进入线性 mux 流。客户端 Session 接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史回放不会消费后来复用同一 `MessageId` 的单次入队项。因此,ChatView 无需扫描持久历史就能每次只渲染一份权威,持久投影则会根据已记录的事件时间与序号恢复时钟、复制与 fork 操作。追加失败时,已认领行仍会退役。 -现有 `session.prompt(mode: 'steer')` 对主会话新输入仍采用尽力而为的契约:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该契约。浏览器本地的提交策略拥有持久化的繁忙态 Enter 偏好,并且只为支持 steering 的会话把普通 Enter 与加速 Enter 解析为互补手势;Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为任一种负面结果都会经原 Queue 单次入队项收敛。 +现有 `session.prompt(mode: 'steer')` 对主会话新输入仍采用尽力而为的契约:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该契约。浏览器提交策略拥有实时繁忙态 Enter 偏好,而 Host settings 服务拥有持久性;该策略只为支持 steering 的会话把普通 Enter 与加速 Enter 解析为互补手势,Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为任一种负面结果都会经原 Queue 单次入队项收敛。 ### 验证 diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml index d1fb6cb6c7..05e3f4b9e2 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md -2026-07-31-browser-derived-initial-locale.md: 0c49a6bbfec0ab33a5aa3ce53dde0cac747f3816 -2026-07-31-browser-derived-initial-locale.zh.md: c013d24dcd3bb49d176eaddd42ff41dde320ff1f +2026-07-31-browser-derived-initial-locale.md: 3fed32ad46f01ef3f88f3182a1cb21f40031ca1b +2026-07-31-browser-derived-initial-locale.zh.md: d47243fb6c9dc2269e1401454b92a528a0f4476a diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md index 0c49a6bbfe..3fed32ad46 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md @@ -10,15 +10,15 @@ The Settings Language row opened every first visit in Chinese: `LocaleService` r ## Decision -**The initial locale resolves through three ordered sources: the persisted preference, then the browser, then `FALLBACK_LOCALE`.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and is the only place the order is expressed; `restorePreference()` now returns `LocaleId | undefined` (an absent, unparseable, or unreachable store reads as *no preference*) so the next source can speak. +**The provisional locale resolves through the browser, then `FALLBACK_LOCALE`; an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active. **Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express. **`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language (`en-US` on the CI runners), so gating on `navigator` would have let a node boot of the client tree resolve to `en` instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`. -**An explicit choice is permanent.** `setLocale` persistence is untouched, and the persisted value is consulted first, so a user who picked a language keeps it even when travelling between browser profiles or system languages. Nothing writes the detected locale back to storage: detection is re-derived every boot and stays invisible to the "has the user chosen?" question. +**An explicit choice is durable.** `setLocale` writes through the Host settings API, so a user who picked a language keeps it across browser origins and system languages that share the same DSH home. Nothing writes the detected locale back: detection is re-derived every boot and stays invisible to the “has the user chosen?” question. -**The browser e2e lane now pins the browser language, not just storage.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` keeps pinning `dsh.locale=en`, which still wins over any browser language. `settings-chrome.e2e.ts` gained a scenario opening a second `en-US` page with empty storage and asserting the settings surface comes up English — the assembled-app proof of this feature. +**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale and asserts its English browser produces an English settings surface—the assembled-app proof of this feature. ## Alternatives considered @@ -33,4 +33,4 @@ The Settings Language row opened every first visit in Chinese: `LocaleService` r - A first visit from an English browser lands in English, and the Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction. - `FALLBACK_LOCALE` narrows to its real job — the dictionary fallback and the no-signal answer — and stops standing in for "the user has not chosen". - Tests that construct a `LocaleService` under jsdom now depend on the environment's `navigator`: specs asserting localized copy declare their browser with one suite-level `usePinnedBrowserLanguages('zh-CN')` (dsh-client-test-runtime), and any future spec asserting a default must do the same. This package's own specs stub the globals directly, because they need shapes the helper deliberately cannot express (absent `languages`, a list decoupled from `language`, no `window` at all). -- Detection cost is one array walk per service construction, and no storage write, so boot behavior and the persisted-state surface are unchanged. +- Detection cost is one array walk per service construction and no implicit settings write; an explicit Host preference may cause one live convergence after plugin activation. diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md index c013d24dcd..d47243fb6c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md @@ -10,15 +10,15 @@ Status: implemented ## Decision -**初始 locale 依次经三个来源解析:已持久化的偏好、浏览器、`FALLBACK_LOCALE`。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,是这一顺序的唯一表达处;`restorePreference()` 现在返回 `LocaleId | undefined`(存储项缺失、无法解析或不可访问,一律读作*没有偏好*),后一个来源才有开口的机会。 +**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE` 解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。 **浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN` 与 `zh-TW` 同归 `zh`、`en-GB` 归 `en`;而只请求本应用不提供的语言(`fr`、`de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。 **判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言(CI runner 上是 `en-US`),因此以 `navigator` 把关会让 node 启动客户端树时解析成 `en`,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。 -**显式选择是永久的。** `setLocale` 的持久化未作改动,且持久化值最先被查询,因此选过语言的用户即便在不同浏览器配置或系统语言之间辗转也保留原选择。没有任何代码把探测到的 locale 写回存储:探测在每次启动时重新推导,对"用户是否做过选择"这一问题始终不可见。 +**显式选择具有持久性。** `setLocale` 通过 Host settings API 写入,因此选过语言的用户可在共享同一 DSH home 的不同浏览器 origin 与系统语言之间保留原选择。没有任何代码把探测到的 locale 写回:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。 -**浏览器 e2e 车道现在钉住浏览器语言,而不只是存储项。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 仍然钉 `dsh.locale=en`,它依旧压过任何浏览器语言。`settings-chrome.e2e.ts` 新增一个场景:另开一个存储项为空的 `en-US` 页面,断言设置界面以英文呈现——这是本功能在组装后应用中的证据。 +**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 使用没有显式 locale 的全新 Host home,断言其英文浏览器会生成英文 settings 界面:这是本功能在组装后应用中的证据。 ## Alternatives considered @@ -33,4 +33,4 @@ Status: implemented - 来自英文浏览器的首访落在英文界面,而语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。 - `FALLBACK_LOCALE` 收窄回它真正的职责——字典回落与无信号时的答案——不再兼职充当"用户尚未选择"。 - 在 jsdom 下构造 `LocaleService` 的测试现在依赖环境的 `navigator`:断言本地化文案的用例以一行套件级 `usePinnedBrowserLanguages('zh-CN')`(dsh-client-test-runtime)声明其浏览器,今后任何断言默认值的用例同样如此。本包自己的用例直接给全局打桩,因为它们需要该 helper 刻意不表达的形状(`languages` 缺失、列表与 `language` 解耦、完全没有 `window`)。 -- 探测的代价是每次服务构造遍历一次数组,且不写存储,因此启动行为与持久化状态面均无变化。 +- 探测的代价是每次服务构造遍历一次数组,且不会隐式写入 settings;插件激活后,显式 Host 偏好可能引发一次实时收敛。 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 638e91a016..9373b3fe79 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: f8519a9622d2f7216226a695db95dbebdbf24ea1 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 294f3e840e0242d9a0d9c53ac510d44d3b0d100f +2026-07-24-web-gui-browser-e2e-lane.md: 7bbe584fe75973aa5da22054e1b220538328d153 +2026-07-24-web-gui-browser-e2e-lane.zh.md: f966dd494b64b17f7692a7aa55161ebc98dc393e 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 f8519a9622..7bbe584fe7 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 @@ -28,7 +28,7 @@ The barrier stack for replay-mode browser assertions is, in order: (1) host-side No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly. -Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios set `dsh.locale=en` before client boot so localized role locators and goldens use one explicit language; the scenarios asserting Chinese copy leave storage unset and open a `zh-CN` browser instead, because the client derives its initial locale from `navigator` ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)), and `settings-chrome.e2e.ts` additionally covers both switch directions and the English-browser default. +Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home. ### Expected outputs 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 294f3e840e..f966dd494b 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 @@ -28,7 +28,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。 -每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景在客户端启动前设置 `dsh.locale=en`,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则不预设该存储项,改为开启 `zh-CN` 浏览器,因为客户端的初始 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md)),而 `settings-chrome.e2e.ts` 还额外覆盖双向切换与英文浏览器默认态。 +每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。 ### 预期输出 diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 0e168ba9fe..d4244e8495 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -18,11 +18,46 @@ import { AppWebEntry } from '@deepseek-ai/dsh-client-web' const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { + id: '@deepseek-ai/dsh-client-ui-theme', + dir: 'ui-theme', + url: '/plugins/ui-theme.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-connection', + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-locale', + ], + immediately: true, + }, + { + id: '@deepseek-ai/dsh-client-locale', + dir: 'locale', + url: '/plugins/locale.js', + rev: 'fx', + inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime'], + immediately: true, + }, + { + id: '@deepseek-ai/dsh-client-ui-layout', + dir: 'ui-layout', + url: '/plugins/ui-layout.js', + rev: 'fx', + inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-theme'], + }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { + id: '@deepseek-ai/dsh-client-ui-conversation', + dir: 'ui-conversation', + url: '/plugins/ui-conversation.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-connection', + '@deepseek-ai/dsh-client-locale', + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-layout', + ], + }, { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', @@ -66,7 +101,8 @@ let unmount: (() => void) | undefined export function installAssembledBootEnv(): void { beforeEach(() => { localStorage.clear() - localStorage.setItem('dsh.locale', 'en') + Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true }) + Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true }) document.title = 'DeepSeek Harness' vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => @@ -84,6 +120,9 @@ export function installAssembledBootEnv(): void { document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) document.title = '' history.replaceState(null, '', '/') + const ownNavigator = navigator as unknown as Record + delete ownNavigator.languages + delete ownNavigator.language vi.unstubAllGlobals() }) } diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 919f3b242c..774ceb332f 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -2,9 +2,8 @@ // section switching, both close paths), the Appearance preference row (the // real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> Host settings // -> theme/change -> ui-layout's presenter -> body attribute -> alias token) -// the Language row (settings-scoped localization + persisted dsh.locale), -// the busy-state Enter preference, plus Permission as the persisted default -// for subsequently created sessions. +// the Language row and busy-state Enter preference (both Host-backed), plus +// Permission as the persisted default for subsequently created sessions. // Zero model calls: everything is pure client + persistence state on a blank // frame, so there is no fixture and a stray stream would fail loud on the // open llm seam. @@ -183,19 +182,18 @@ describe('web e2e: settings modal and General preferences', () => { .toMatch(/ui-theme:\n\s+preference: dark/) await page.keyboard.press('Escape') - // Reload: the preference survives boot (restore + presenter initial apply). + // Reload: the preference survives the background Host read + presenter update. const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.emulateMedia({ colorScheme: 'light' }) - const reloaded = await readState() - expect(reloaded.attr).toBe(true) - expect(reloaded.legacy).toBeNull() + await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true) + expect((await readState()).legacy).toBeNull() // A second live Host binds another ephemeral port but shares the same - // user-settings home. Its fresh origin has no theme localStorage and must - // still render dark before the settings dialog opens. + // user-settings home. Its fresh origin has no theme localStorage and still + // converges to dark before the settings dialog opens. const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome }) const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) const secondTripwire = watchConsole(secondPage) @@ -204,9 +202,8 @@ describe('web e2e: settings modal and General preferences', () => { await secondPage.emulateMedia({ colorScheme: 'light' }) await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - const crossPort = await readState(secondPage) - expect(crossPort.attr).toBe(true) - expect(crossPort.legacy).toBeNull() + await expect.poll(async () => (await readState(secondPage)).attr, { timeout: 5_000 }).toBe(true) + expect((await readState(secondPage)).legacy).toBeNull() expect(secondTripwire.pageErrors).toEqual([]) expect(secondTripwire.warnings).toEqual([]) } finally { @@ -230,7 +227,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 90_000) - it('persists the busy-state Enter behavior across reload and restores Queue', async () => { + it('persists the busy-state Enter behavior across reload and a distinct port', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior')) await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) @@ -238,7 +235,9 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.getByRole('button', { name: '排队发送' }).click() await page.getByRole('menuitem', { name: '插话发送' }).click() await dialog.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 }) - expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('steer') + expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull() + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-conversation:\n\s+busyEnter: steer/) await page.keyboard.press('Escape') const warningStart = tripwire.warnings.length @@ -248,15 +247,36 @@ describe('web e2e: settings modal and General preferences', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const reloaded = page.getByRole('dialog', { name: '设置' }) await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 }) + + const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome }) + const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const secondTripwire = watchConsole(secondPage) + try { + expect(second.baseUrl).not.toBe(scaffold.baseUrl) + await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) + await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await secondPage.getByRole('button', { name: '设置', exact: true }).click() + await secondPage.getByRole('dialog', { name: '设置' }) + .getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 }) + expect(await secondPage.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull() + expect(secondTripwire.pageErrors).toEqual([]) + expect(secondTripwire.warnings).toEqual([]) + } finally { + await secondPage.close() + await second.close() + } + await reloaded.getByRole('button', { name: '插话发送' }).click() await page.getByRole('menuitem', { name: '排队发送' }).click() await reloaded.getByRole('button', { name: '排队发送' }).waitFor({ timeout: 10_000 }) - expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('queue') + expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull() + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-conversation:\n\s+busyEnter: queue/) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 90_000) - it('switches the settings surface language and persists dsh.locale', async () => { + it('persists the settings language across reload and a distinct port', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language')) await page.getByRole('button', { name: '设置', exact: true }).click() const zhDialog = page.getByRole('dialog', { name: '设置' }) @@ -273,7 +293,9 @@ describe('web e2e: settings modal and General preferences', () => { await enDialog.waitFor({ timeout: 10_000 }) expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true') await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1) - expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('en') + expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/locale:\n\s+preference: en/) // Reload keeps English; then restore zh so shared page state (and the // other specs' 设置-anchored selectors + goldens) see the default again. const warningStart = tripwire.warnings.length @@ -282,24 +304,47 @@ describe('web e2e: settings modal and General preferences', () => { acknowledgeReloadConnectionLoss(tripwire, warningStart) const enTrigger = page.getByRole('button', { name: 'Settings' }) await enTrigger.waitFor({ timeout: 10_000 }) + + // A Chinese browser on another port still receives the explicit English + // preference from the shared Host settings document. + const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome }) + const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const secondTripwire = watchConsole(secondPage) + try { + expect(second.baseUrl).not.toBe(scaffold.baseUrl) + await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) + await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await secondPage.getByRole('button', { name: 'Settings', exact: true }).click() + await secondPage.getByRole('dialog', { name: 'Settings' }) + .getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + expect(await secondPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() + expect(secondTripwire.pageErrors).toEqual([]) + expect(secondTripwire.warnings).toEqual([]) + } finally { + await secondPage.close() + await second.close() + } + await enTrigger.click() await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click() await page.getByRole('menuitem', { name: '中文' }).click() await page.getByRole('dialog', { name: '设置' }).waitFor({ timeout: 10_000 }) - expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('zh') + expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/locale:\n\s+preference: zh/) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 90_000) it('opens an English browser in English without any stored preference', async () => { - // A second page under a different browser language: nothing is persisted - // for it, so the settings surface must follow the browser rather than the - // product fallback the shared zh page shows. + // A fresh Host home has no locale preference, so its surface follows the + // browser rather than the product fallback. + const fresh = await launchWebScaffold({}) const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' }) const enTripwire = watchConsole(enPage) onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language')) try { - await enPage.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await enPage.goto(fresh.baseUrl, { waitUntil: 'load' }) await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() await enPage.getByRole('button', { name: 'Settings', exact: true }).click() @@ -312,6 +357,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(enTripwire.warnings).toEqual([]) } finally { await enPage.close() + await fresh.close() } }, 90_000) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 1b7b67aab3..40b9be39ca 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -18,18 +18,17 @@ export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) export const ZH_BROWSER_LOCALE = 'zh-CN' /** - * Open the standard browser-test page with English selected before client - * boot. This keeps role locators and goldens deterministic across localized - * component migrations; the scenarios asserting the Chinese surface bypass - * this helper and advertise {@link ZH_BROWSER_LOCALE} instead. + * Open the standard browser-test page advertising English before client boot. + * This keeps role locators and goldens deterministic while leaving the Host + * settings document free to override the provisional browser-derived locale; + * scenarios asserting the Chinese surface advertise + * {@link ZH_BROWSER_LOCALE} instead. * @param browser - Playwright browser owning the page. * @param height - Viewport height; width is fixed to the lane baseline. * @returns the initialized page. */ export async function newEnglishPage(browser: Browser, height = 1000): Promise { - const page = await browser.newPage({ viewport: { width: 1680, height } }) - await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') }) - return page + return await browser.newPage({ viewport: { width: 1680, height }, locale: 'en-US' }) } /** Fail loud on a stale checkout instead of testing yesterday's bundle. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 59b20a6762..e0c5ec838a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -62,14 +62,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `commands/changed` | `runtime` (`emit`) | `ui-command` | -| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general`, `ui-theme` | +| `connection/reset` | `runtime` (`emit`) | `runtime`, `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | -| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general`, `ui-theme` | +| `settings/changed` | `runtime` (`emit`) | `runtime`, `ui-models`, `ui-permission`, `ui-settings-general` | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | | `slash/input-insert-reference` | - | `ui-conversation` | diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index d1ef53207f..3918beb028 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/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/locale/README.md -README.md: f1efefde4557e1c29c0556f8b670f1534430ab79 -README.zh.md: a8b5704d28ea121e668cbd500dd3d217d4f96291 +README.md: 5bea46cd4e3ace61bd2251610abdf0812ded9604 +README.zh.md: 2333bc7c2b2f5918c35286064c50131153ee8711 diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index f1efefde45..5bea46cd4e 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; with nothing persisted a fresh browser opens in the language `navigator` asks for — matched on the primary subtag, `zh` when it asks for none this app ships; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). +Locale plugin: LocaleService — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `zh` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → zh → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. ## Model Experience diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index a8b5704d28..2333bc7c2b 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 持久化;未持久化偏好时,全新浏览器以 `navigator` 请求的语言开场——按主子标签匹配,若其请求的语言本应用都不提供则为 `zh`;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。 +locale 插件:LocaleService——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `zh`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → zh → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 ## 模型体验 diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 75742a80d0..cfadff76b8 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-locale", - "description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t); registers the Language settings row", + "description": "Locale plugin: Host-backed zh/en preference, browser-derived fallback, locale snapshots, and typed namespace dictionaries", "version": "0.0.1", "private": true, "type": "module", @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-runtime" ], "platform": "web", @@ -31,6 +32,7 @@ }, "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", @@ -47,6 +49,10 @@ "cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, + "dependencies": { + "@deepseek-ai/dsh-settings": "workspace:^", + "schemastery": "^3.18.0" + }, "files": [ "lib/index.js", "lib/invariant.js", diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 5d195ee275..ac694b0bce 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -13,7 +13,10 @@ import type { Context } from 'cordis' import { type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS, } from '@deepseek-ai/dsh-client-ui-slots' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSettingsPreference, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { + isLocaleId, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, +} from '../locale-settings.ts' import { en, zh, type CommonKey } from '../locales/index.ts' import { en as settingsEn, zh as settingsZh, type SettingsLocaleKey, @@ -26,6 +29,9 @@ export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageR export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts' export type { CommonKey } from '../locales/index.ts' +export { + LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, +} from '../locale-settings.ts' // The translate currency lives in ui-slots (the render machinery synthesizes // the seat); re-exported here so dictionary owners import one package. @@ -44,9 +50,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Locale dictionary: flat key to template string ({name} placeholders). */ export type LocaleDict = Record -/** Locale identifier: the two shipped locales. */ -export type LocaleId = 'zh' | 'en' - /** One selectable locale: id plus its self-described display name. */ export interface LocaleDefinition { /** Locale id (persisted; the setLocale argument). */ @@ -91,9 +94,6 @@ export const COMMON_NS = 'common' /** Namespace owning this feature's settings-row copy. */ export const SETTINGS_NS = 'settings.locale' -/** localStorage key holding the persisted locale id. */ -export const STORAGE_KEY = 'dsh.locale' - /** The two shipped locales. */ const LOCALES: readonly LocaleDefinition[] = Object.freeze([ { id: 'zh', label: '中文' }, @@ -116,15 +116,26 @@ export class LocaleService { private snapshot: LocaleSnapshot private listeners = new Set<() => void>() private readonly ctx: Context + private persist: (id: LocaleId) => void /** * @param ctx - owning context (change events are emitted on it). + * @param persist - durable write callback for explicit locale selections. */ - constructor(ctx: Context) { + constructor(ctx: Context, persist: (id: LocaleId) => void = () => {}) { this.ctx = ctx + this.persist = persist this.snapshot = Object.freeze({ active: resolveInitialLocale(), locales: LOCALES, revision: 0 }) } + /** + * Bind the owning plugin's durable writer before the service is provided. + * @param persist - callback accepting explicit locale changes. + */ + bindPersistence(persist: (id: LocaleId) => void): void { + this.persist = persist + } + /** * Read the current immutable locale snapshot. * @returns the current snapshot (stable reference until the next change). @@ -155,16 +166,24 @@ export class LocaleService { } /** - * Switch the active locale — the only preference write entry. Persists the - * id and emits `locale/change`. + * Switch the active locale — the only user preference write entry. * @param id - a registered locale id; unknown ids throw. */ setLocale(id: string): void { const match = this.snapshot.locales.find(l => l.id === id) if (match === undefined) throw new Error(`locale "${id}" is not registered`) if (this.snapshot.active === match.id) return - persistPreference(match.id) this.publish(match.id, true) + this.persist(match.id) + } + + /** + * Apply an explicit Host preference without writing it back. + * @param id - validated shipped locale. + */ + syncPreference(id: LocaleId): void { + if (this.snapshot.active === id) return + this.publish(id, true) } /** @@ -288,27 +307,11 @@ export class LocaleService { } /** - * The locale a fresh service opens with: an explicit preference the user - * already chose wins over the browser's own language, which in turn wins over - * {@link FALLBACK_LOCALE} (non-browser boots and browsers set to a language - * this app does not ship). + * The browser's own language wins over {@link FALLBACK_LOCALE}; an explicit + * Host preference may replace this provisional value after plugin activation. */ function resolveInitialLocale(): LocaleId { - return restorePreference() ?? detectBrowserLocale() ?? FALLBACK_LOCALE -} - -/** Read the persisted locale id; unknown or unreadable values read as no preference. */ -function restorePreference(): LocaleId | undefined { - // Non-browser runs (node e2e booting the client tree) have no localStorage. - if (typeof localStorage === 'undefined') return undefined - try { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored === 'zh' || stored === 'en') return stored - } catch { - // Storage access can throw (privacy mode); an unreadable store simply - // records no preference, and the browser language decides instead. - } - return undefined + return detectBrowserLocale() ?? FALLBACK_LOCALE } /** @@ -325,8 +328,7 @@ function detectBrowserLocale(): LocaleId | undefined { /* oxlint-disable-next-line typescript/no-unnecessary-condition -- * The DOM lib types `languages` as always present; embedders and older * WebViews ship a Navigator without it, and spreading undefined would - * throw at boot. Same environment-boundary distrust as the localStorage - * guards below. */ + * throw at boot. */ for (const tag of [...(navigator.languages ?? []), navigator.language]) { const primary = tag.toLowerCase().split('-')[0] const match = LOCALES.find(locale => locale.id === primary) @@ -335,19 +337,8 @@ function detectBrowserLocale(): LocaleId | undefined { return undefined } -/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */ -function persistPreference(id: LocaleId): void { - if (typeof localStorage === 'undefined') return - try { - localStorage.setItem(STORAGE_KEY, id) - } catch { - // Storage access can throw (privacy mode / quota); the preference simply - // does not survive the session. - } -} - -/** Required services: the slot registry (the feature registers its own settings row). */ -export const inject = ['slots'] +/** Required services: slot registration plus the settings transport. */ +export const inject = ['slots', 'connection'] /** * Client plugin body: provide the locale service with base dictionaries and @@ -357,8 +348,16 @@ export const inject = ['slots'] */ export function apply(ctx: ClientContext): void { const locale = new LocaleService(ctx) + const browserLocale = locale.getLocale().active locale.register(COMMON_NS, { zh, en }) locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn }) + const controller = bindSettingsPreference(ctx, { + namespace: LOCALE_SETTINGS_NAMESPACE, + field: LOCALE_PREFERENCE_FIELD, + decode: value => isLocaleId(value) ? value : browserLocale, + sync: (id) => { locale.syncPreference(id) }, + }) + locale.bindPersistence((id) => { void controller.persist(id) }) ctx.provide('locale', locale) // The service IS the LocaleFace (bind + getSnapshot/subscribe): install it // so the render machinery can synthesize the `t` standard seat. diff --git a/packages/client/locale/src/index.ts b/packages/client/locale/src/index.ts index c220373932..09afbef04e 100644 --- a/packages/client/locale/src/index.ts +++ b/packages/client/locale/src/index.ts @@ -1,4 +1,33 @@ -/** Host loader entry for the browser implementation exported from `./client`. */ +/** Host registration for the browser locale preference. */ -/** Host plugin body — no host-side behavior for the locale plugin. */ -export function apply(): void {} +import type { Context } from 'cordis' +import z from 'schemastery' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, +} from './locale-settings.ts' + +export { + LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, +} from './locale-settings.ts' + +interface LocaleSettings { + preference?: LocaleId +} + +const LocaleSettingsSchema: z = z.object({ + [LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false), +}) + +/** + * Register the durable locale section when a settings provider exists. + * @param ctx - Host context whose optional settings service owns the section. + */ +export function apply(ctx: Context): void { + ctx.inject(['settings'], (settingsCtx) => { + settingsCtx.settings.register( + settingsNamespace(LOCALE_SETTINGS_NAMESPACE), + LocaleSettingsSchema, + ) + }) +} diff --git a/packages/client/locale/src/locale-settings.ts b/packages/client/locale/src/locale-settings.ts new file mode 100644 index 0000000000..dd1ad39339 --- /dev/null +++ b/packages/client/locale/src/locale-settings.ts @@ -0,0 +1,22 @@ +/** Locale preference stored in the Host user-settings document. */ + +/** Settings namespace owned by the locale plugin. */ +export const LOCALE_SETTINGS_NAMESPACE = 'locale' + +/** Field carrying an explicit locale selection; absence delegates to the browser. */ +export const LOCALE_PREFERENCE_FIELD = 'preference' + +/** Locale identifiers shipped by the browser client. */ +export const LOCALE_IDS = ['zh', 'en'] as const + +/** Shipped locale identifier. */ +export type LocaleId = typeof LOCALE_IDS[number] + +/** + * Narrow one settings-wire value to a shipped locale. + * @param value - value crossing the settings boundary. + * @returns whether the value names a shipped locale. + */ +export function isLocaleId(value: unknown): value is LocaleId { + return LOCALE_IDS.some(locale => locale === value) +} diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index a3007f8c78..2bd424a974 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -4,7 +4,9 @@ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-locale/client' +import { + apply, inject, LOCALE_SETTINGS_NAMESPACE, SETTINGS_NS, +} from '@deepseek-ai/dsh-client-locale/client' import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { createLanguageRowStore } from '../src/client/settings-store.ts' @@ -14,7 +16,36 @@ const SLOT = 'settings.general.item' async function bench() { const ctx = new Context() await ctx.plugin(SlotsService).await() - return { ctx, slots: ctx.get('slots') as SlotsService } + let preference: string | undefined + let revision = 0 + const namespace = () => ({ + ns: LOCALE_SETTINGS_NAMESPACE, + schema: {}, + value: preference === undefined ? {} : { preference }, + applies: 'live' as const, + secrets: [], + revision, + }) + const describe = vi.fn(async () => ({ + rpcId: 'locale-describe' as never, + result: { + ok: true as const, + value: { writable: true, hasDocument: true, namespaces: [namespace()] }, + }, + })) + const mutate = vi.fn(async (request: { ops: { value: string }[] }) => { + preference = request.ops[0]!.value + revision += 1 + return { + rpcId: 'locale-mutate' as never, + result: { ok: true as const, value: namespace() }, + } + }) + ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never) + return { + ctx, slots: ctx.get('slots') as SlotsService, describe, mutate, + setHostPreference: (next: string | undefined) => { preference = next; revision += 1 }, + } } /** Stand in for the settings shell: declare the General item slot from root. */ @@ -47,7 +78,7 @@ describe('locale apply', () => { }) it('declares the slot service', () => { - expect(inject).toEqual(['slots']) + expect(inject).toEqual(['slots', 'connection']) }) it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => { @@ -91,6 +122,23 @@ describe('locale apply', () => { expect(locale.getLocale().active).toBe('zh') expect(instance.getSnapshot().active).toBe('zh') expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言') + await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) }) + }) + + it('loads and refreshes the explicit Host preference after nonblocking activation', async () => { + const b = await bench() + b.setHostPreference('en') + declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const locale = b.ctx.get('locale') as LocaleService + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) + b.setHostPreference(undefined) + b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE) + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) + b.setHostPreference('en') + b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE) + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) + expect(b.describe).toHaveBeenCalledTimes(3) }) it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { diff --git a/packages/client/locale/tests/host.spec.ts b/packages/client/locale/tests/host.spec.ts new file mode 100644 index 0000000000..8fa339e660 --- /dev/null +++ b/packages/client/locale/tests/host.spec.ts @@ -0,0 +1,30 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { + LOCALE_SETTINGS_NAMESPACE, apply, +} from '@deepseek-ai/dsh-client-locale' + +class MemorySettings extends Settings { + readonly writable = true + protected load(): Promise> { return Promise.resolve({}) } + protected persist(_ns: SettingsNamespace, _section: Record): Promise { + return Promise.resolve() + } +} + +describe('locale host', () => { + it('registers an optional explicit locale preference with the Host settings lifecycle', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings).await() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const ns = settingsNamespace(LOCALE_SETTINGS_NAMESPACE) + expect(ctx.settings.get(ns)).toEqual({}) + await ctx.settings.update(ns, { preference: 'en' }) + expect(ctx.settings.get(ns)).toEqual({ preference: 'en' }) + await expect(ctx.settings.update(ns, { preference: 'fr' })).rejects.toThrow() + await fiber.dispose() + expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns) + }) +}) diff --git a/packages/client/locale/tests/invariant.spec.ts b/packages/client/locale/tests/invariant.spec.ts index fa62ca79f6..2b362cb115 100644 --- a/packages/client/locale/tests/invariant.spec.ts +++ b/packages/client/locale/tests/invariant.spec.ts @@ -14,16 +14,16 @@ describe('invariant companion', () => { await expect(ctx.plugin(LocaleInvariant).await()).resolves.toBeDefined() }) - it('node-half apply is a no-op host placeholder', () => { - nodeApply() - expect(true).toBe(true) // reaching here without throw is the contract + it('node-half apply tolerates a Host without settings', () => { + nodeApply(new Context()) }) it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => { // The feature registers its own Language settings row, hence the slots edge. - expect(inject).toEqual(['slots']) + expect(inject).toEqual(['slots', 'connection']) const ctx = new Context() new SlotsService(ctx) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await ctx.plugin({ inject, apply: clientApply }).await() const locale = ctx.get('locale') expect(locale).toBeInstanceOf(LocaleService) diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts index 442701dbb3..9215bd51e6 100644 --- a/packages/client/locale/tests/locale.spec.ts +++ b/packages/client/locale/tests/locale.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' -import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => { const ctx = new Context() @@ -24,7 +24,6 @@ const stubLanguages = (...tags: string[]): void => { describe('LocaleService', () => { beforeEach(() => { - localStorage.clear() // A Chinese browser is the baseline these specs assert their zh state on. stubLanguages('zh-CN') }) @@ -132,16 +131,19 @@ describe('LocaleService', () => { expect(svc.getSnapshot().revision).toBe(before + 1) }) - it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => { + it('setLocale requests persistence, republishes an immutable snapshot, and no-ops on same value', () => { const { svc, events } = make() + const persist = vi.fn() + svc.bindPersistence(persist) svc.setLocale('en') expect(svc.getLocale().active).toBe('en') - expect(localStorage.getItem(STORAGE_KEY)).toBe('en') + expect(persist).toHaveBeenCalledWith('en') expect(events).toHaveLength(1) expect(events[0]).toBe(svc.getLocale()) expect(events[0]!.revision).toBe(1) svc.setLocale('en') expect(events).toHaveLength(1) + expect(persist).toHaveBeenCalledOnce() }) it('throws on unknown locale ids', () => { @@ -149,14 +151,19 @@ describe('LocaleService', () => { expect(() => { svc.setLocale('fr') }).toThrow('not registered') }) - it('restores a persisted locale over the browser language, and garbage reads as no preference', () => { - localStorage.setItem(STORAGE_KEY, 'en') - expect(make().svc.getLocale().active).toBe('en') - localStorage.setItem(STORAGE_KEY, 'fr') - expect(make().svc.getLocale().active).toBe('zh') + it('syncs a Host preference over the browser language without writing it back', () => { + const { svc, events } = make() + const persist = vi.fn() + svc.bindPersistence(persist) + svc.syncPreference('en') + expect(svc.getLocale().active).toBe('en') + expect(events).toHaveLength(1) + expect(persist).not.toHaveBeenCalled() + svc.syncPreference('en') + expect(events).toHaveLength(1) }) - it('opens in the browser language when nothing is persisted, matching regional variants on their primary subtag', () => { + it('opens provisionally in the browser language, matching regional variants on their primary subtag', () => { stubLanguages('en-GB', 'zh-CN') expect(make().svc.getLocale().active).toBe('en') stubLanguages('zh-Hant-TW') @@ -176,8 +183,7 @@ describe('LocaleService', () => { expect(make().svc.getLocale().active).toBe('zh') }) - it('runs outside a browser (node boots): the fallback decides, the machine language does not, writes no-op', () => { - vi.stubGlobal('localStorage', undefined) + it('runs outside a browser (node boots): the fallback decides and the machine language does not', () => { vi.stubGlobal('window', undefined) // Node exposes its own global navigator; without a window it must not // reach the resolution at all. @@ -188,12 +194,11 @@ describe('LocaleService', () => { expect(svc.getLocale().active).toBe('en') }) - it('keeps the browser language out of the way once a preference exists', () => { + it('lets an explicit in-process preference replace the browser-derived value', () => { stubLanguages('en-US') const { svc } = make() svc.setLocale('zh') - expect(localStorage.getItem(STORAGE_KEY)).toBe('zh') - expect(make().svc.getLocale().active).toBe('zh') + expect(svc.getLocale().active).toBe('zh') }) it('exposes the two shipped locales with self-described labels', () => { diff --git a/packages/client/locale/tsconfig.json b/packages/client/locale/tsconfig.json index 8585ba74ca..313c11f5bf 100644 --- a/packages/client/locale/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 23c867e4c0..5698297cb4 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: 8ac29a4258bbd7456b20c61e547d48c570e84d27 -README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d +README.md: c05089badb29ad0e22ed1f66d7804eccbb11c1d4 +README.zh.md: ccbb96266cf8ca442adbdbf9784c54400593d5c2 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 8ac29a4258..c05089badb 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -4,6 +4,8 @@ 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`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. 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. +`bindSettingsPreference` is the browser lifecycle for one domain-owned scalar setting. It subscribes before starting a nonblocking initial read, serializes writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. Loopback pages use the Host settings API; remote pages stay in memory. Domain packages own the namespace schema, value guard, 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. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 0e065e43ec..ccbb96266c 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -4,6 +4,8 @@ 客户端 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`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 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` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +`bindSettingsPreference` 是单项由领域持有的标量设置所用的浏览器生命周期。它在开始非阻塞初始读取前建立订阅,使用已知最新 namespace revision 串行写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。回环页面使用 Host settings API,远程页面则只保留内存状态。namespace schema、取值校验器、默认值与实时服务归领域包所有,而非把产品政策放入运行时。 + ## Slot 声明注入 `ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 06f88a9131..2854a16659 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -21,6 +21,8 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' +export { bindSettingsPreference, SettingsPreferenceController } from './settings-preference.ts' +export type { SettingsPreferenceSpec } from './settings-preference.ts' export type { Session } from './sessions/session.ts' export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' export type { diff --git a/packages/client/runtime/src/client/settings-preference.ts b/packages/client/runtime/src/client/settings-preference.ts new file mode 100644 index 0000000000..a459999cc7 --- /dev/null +++ b/packages/client/runtime/src/client/settings-preference.ts @@ -0,0 +1,160 @@ +/** Host-backed scalar preference synchronization for browser plugins. */ + +import type { Context } from 'cordis' +import type { + ConnectionHandle, IApiClient, SettingsNamespaceView, +} from '@deepseek-ai/dsh-client-connection/client' + +/** Domain-owned description of one scalar field in a settings namespace. */ +export interface SettingsPreferenceSpec { + /** Settings namespace registered by the owning Host plugin. */ + namespace: string + /** Scalar field inside that namespace. */ + field: string + /** Validate a wire value; undefined leaves the current in-process value active. */ + decode(value: unknown): T | undefined + /** Apply a validated Host value without writing it back. */ + sync(value: T): void +} + +type SettingsFace = Pick + +/** + * Serializes one scalar preference's Host reads and writes. Reads never block + * plugin activation; writes carry the latest known namespace revision and + * teardown waits for the operation already crossing the wire. + */ +export class SettingsPreferenceController { + private tail: Promise = Promise.resolve() + private readGeneration = 0 + private writeGeneration = 0 + private revision: number | undefined + private disposed = false + + /** + * @param api - settings wire face. + * @param spec - namespace, field validator, and live target. + * @param persistence - remote browsers remain process-local because settings RPCs are loopback-only. + */ + constructor( + private readonly api: SettingsFace, + private readonly spec: SettingsPreferenceSpec, + private readonly persistence: 'host' | 'memory' = 'host', + ) {} + + /** + * Queue a Host refresh; a newer read or user write suppresses stale publication. + * @returns settlement after the queued read completes or is skipped. + */ + load(): Promise { + const generation = ++this.readGeneration + return this.enqueue(() => this.read(generation)) + } + + /** + * Queue one user preference write. Rapid selections preserve mutation order, + * while only the latest settlement may resynchronize the live target. + * @param value - validated domain preference selected by the user. + * @returns settlement after the write and any latest-write recovery read. + */ + persist(value: T): Promise { + this.readGeneration += 1 + const generation = ++this.writeGeneration + return this.enqueue(async () => { + let response: Awaited> + try { + response = await this.api.settings.mutate({ + ns: this.spec.namespace, + ops: [{ op: 'set', path: [this.spec.field], value }], + ...(this.revision === undefined ? {} : { expectedRevision: this.revision }), + }) + } catch (_settingsWriteFailure) { + if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + return + } + if (!response.result.ok) { + if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + return + } + this.accept(response.result.value, generation === this.writeGeneration) + }) + } + + /** + * Stop queued operations and wait for the current wire call to settle. + * @returns settlement after the controller reaches quiescence. + */ + async dispose(): Promise { + this.disposed = true + this.readGeneration += 1 + this.writeGeneration += 1 + await this.tail + } + + private enqueue(operation: () => Promise): Promise { + if (this.persistence === 'memory' || this.disposed) return Promise.resolve() + const task = this.tail.then(async () => { + if (this.disposed) return + await operation() + }) + // The returned task carries its own settlement to the caller; the queue + // tail is kept fulfilled so one failed target callback cannot strand later operations. + this.tail = task.catch(() => {}) + return task + } + + private async read(generation: number): Promise { + let response: Awaited> + try { + response = await this.api.settings.describe({}) + } catch (_settingsReadFailure) { + return + } + if (!response.result.ok || this.disposed) return + const view = response.result.value.namespaces.find(candidate => candidate.ns === this.spec.namespace) + if (view === undefined) return + this.accept(view, generation === this.readGeneration) + } + + private accept(view: SettingsNamespaceView, publish: boolean): void { + this.revision = view.revision + if (!publish || typeof view.value !== 'object' || view.value === null) return + const value = this.spec.decode((view.value as Record)[this.spec.field]) + if (value !== undefined) this.spec.sync(value) + } +} + +/** + * Bind one controller to settings and connection invalidations on the caller's + * plugin lifecycle. Listeners exist before the initial background read starts. + * @param ctx - owning browser plugin context. + * @param spec - domain-owned scalar preference contract. + * @returns the bound controller used by the domain's user-write callback. + */ +export function bindSettingsPreference( + ctx: Context, + spec: SettingsPreferenceSpec, +): SettingsPreferenceController { + const connection = ctx.get('connection') as ConnectionHandle + const controller = new SettingsPreferenceController( + connection.api, + spec, + connection.isLoopback ? 'host' : 'memory', + ) + ctx.effect(() => { + const refresh = (namespace?: string): void => { + if (namespace !== undefined && namespace !== spec.namespace) return + void controller.load() + } + const disposers = [ + ctx.on('settings/changed', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + void controller.load() + return async () => { + for (const dispose of disposers) dispose() + await controller.dispose() + } + }, `runtime: ${spec.namespace}.${spec.field} preference`) + return controller +} diff --git a/packages/client/runtime/tests/settings-preference.spec.ts b/packages/client/runtime/tests/settings-preference.spec.ts new file mode 100644 index 0000000000..a93df780bb --- /dev/null +++ b/packages/client/runtime/tests/settings-preference.spec.ts @@ -0,0 +1,237 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { + bindSettingsPreference, SettingsPreferenceController, +} from '../src/client/settings-preference.ts' + +type Preference = 'light' | 'dark' | 'system' + +let rpc = 0 + +function ok(value: T): RpcResponse { + return { rpcId: `preference-${rpc++}` as never, result: { ok: true, value } } +} + +function rejected(): RpcResponse { + return { + rpcId: `preference-${rpc++}` as never, + result: { + ok: false, + error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } }, + }, + } +} + +function view(value: unknown, revision = 0): SettingsNamespaceView { + return { + ns: 'ui-test', + schema: {}, + value, + applies: 'live', + secrets: [], + revision, + } +} + +function described(value: unknown, revision = 0) { + return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] }) +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +function spec(values: Preference[]) { + return { + namespace: 'ui-test', + field: 'preference', + decode: (value: unknown): Preference | undefined => + value === 'light' || value === 'dark' || value === 'system' ? value : undefined, + sync: (value: Preference) => { values.push(value) }, + } +} + +describe('SettingsPreferenceController', () => { + it('loads only a valid owned field and contains unavailable transports', async () => { + const values: Preference[] = [] + const describe = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' }, 3)) + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) + .mockResolvedValueOnce(described({ preference: 'sepia' })) + .mockResolvedValueOnce(described(null)) + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + const controller = new SettingsPreferenceController({ settings: { describe } } as never, spec(values)) + for (let i = 0; i < 6; i++) await controller.load() + expect(values).toEqual(['dark']) + }) + + it('serializes rapid writes, carries revisions, and publishes only the latest settlement', async () => { + const first = deferred>() + const values: Preference[] = [] + const describe = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4)) + const mutate = vi.fn() + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 6))) + const controller = new SettingsPreferenceController( + { settings: { describe, mutate } } as never, + spec(values), + ) + await controller.load() + const dark = controller.persist('dark') + const light = controller.persist('light') + await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + first.resolve(ok(view({ preference: 'dark' }, 5))) + await Promise.all([dark, light]) + expect(values).toEqual(['system', 'light']) + expect(mutate).toHaveBeenNthCalledWith(1, { + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'dark' }], + expectedRevision: 4, + }) + expect(mutate).toHaveBeenNthCalledWith(2, { + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'light' }], + expectedRevision: 5, + }) + }) + + it('recovers the latest rejected or thrown write from Host state', async () => { + const values: Preference[] = [] + const describe = vi.fn() + .mockResolvedValueOnce(described({ preference: 'system' }, 2)) + .mockResolvedValueOnce(described({ preference: 'light' }, 3)) + const mutate = vi.fn() + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + const controller = new SettingsPreferenceController( + { settings: { describe, mutate } } as never, + spec(values), + ) + await controller.persist('dark') + await controller.persist('system') + expect(values).toEqual(['system', 'light']) + }) + + it('does not recover superseded rejected or thrown writes', async () => { + const values: Preference[] = [] + const describe = vi.fn() + const mutate = vi.fn() + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) + const controller = new SettingsPreferenceController( + { settings: { describe, mutate } } as never, + spec(values), + ) + await Promise.all([ + controller.persist('dark'), + controller.persist('system'), + controller.persist('light'), + ]) + expect(describe).not.toHaveBeenCalled() + expect(values).toEqual(['light']) + }) + + it('keeps the queue usable when a target callback throws', async () => { + const describe = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' })) + .mockResolvedValueOnce(described({ preference: 'sepia' })) + const controller = new SettingsPreferenceController( + { settings: { describe } } as never, + { ...spec([]), sync: () => { throw new Error('target failed') } }, + ) + await expect(controller.load()).rejects.toThrow('target failed') + await expect(controller.load()).resolves.toBeUndefined() + }) + + it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => { + const first = deferred>() + const mutate = vi.fn().mockReturnValue(first.promise) + const values: Preference[] = [] + const controller = new SettingsPreferenceController( + { settings: { mutate } } as never, + spec(values), + ) + const dark = controller.persist('dark') + await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + const light = controller.persist('light') + let stopped = false + const stop = controller.dispose().then(() => { stopped = true }) + await Promise.resolve() + expect(stopped).toBe(false) + first.resolve(ok(view({ preference: 'dark' }, 1))) + await Promise.all([dark, light, stop]) + await controller.persist('system') + await controller.load() + expect(mutate).toHaveBeenCalledOnce() + expect(values).toEqual([]) + }) + + it('keeps remote-browser preferences in memory without Host calls', async () => { + const describe = vi.fn() + const mutate = vi.fn() + const controller = new SettingsPreferenceController( + { settings: { describe, mutate } } as never, + spec([]), + 'memory', + ) + await controller.load() + await controller.persist('dark') + await controller.dispose() + expect(describe).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() + }) +}) + +describe('bindSettingsPreference', () => { + it('subscribes before the initial read and converges to the latest queued invalidation', async () => { + const initial = deferred>() + const describe = vi.fn() + .mockReturnValueOnce(initial.promise) + .mockResolvedValueOnce(described({ preference: 'light' }, 2)) + .mockResolvedValueOnce(described({ preference: 'system' }, 3)) + const ctx = new Context() + ctx.provide('connection', { + api: { settings: { describe } }, + isLoopback: true, + } as never) + const values: Preference[] = [] + const fiber = ctx.plugin({ + inject: ['connection'], + apply: (scope: Context) => { bindSettingsPreference(scope, spec(values)) }, + }) + await fiber.await() + await vi.waitFor(() => { expect(describe).toHaveBeenCalledOnce() }) + ctx.emit('settings/changed', 'unrelated') + ctx.emit('settings/changed', 'ui-test') + ctx.emit('connection/reset') + initial.resolve(described({ preference: 'dark' }, 1)) + await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(3) }) + await vi.waitFor(() => { expect(values).toEqual(['system']) }) + await fiber.dispose() + ctx.emit('settings/changed', 'ui-test') + await Promise.resolve() + expect(describe).toHaveBeenCalledTimes(3) + }) + + it('binds a remote browser in memory without starting a settings read', async () => { + const describe = vi.fn() + const ctx = new Context() + ctx.provide('connection', { + api: { settings: { describe } }, + isLoopback: false, + } as never) + const fiber = ctx.plugin({ + inject: ['connection'], + apply: (scope: Context) => { bindSettingsPreference(scope, spec([])) }, + }) + await fiber.await() + await fiber.dispose() + expect(describe).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 78169601c8..c1f1278e61 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: 8d6c26f67916f043251c58a3283542bd58a08666 -README.zh.md: 8dd43cca59f8dfda18ce036b5d8c6f948306c947 +README.md: 2789265d867e8e1e23f97e01b2ea7d12960a188c +README.zh.md: 9707c8b64f872fae52bb0c5900f5db403ed75e59 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 8d6c26f679..2789265d86 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,7 +40,7 @@ The todo surfaces are two registrations over that shape, both using slot declara The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. -Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. +Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the Host-backed `ui-conversation.busyEnter` General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; the local settings provider stores it in `$DSH_HOME/settings.yaml`, so the choice follows the same user home across Web ports. Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. 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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8dd43cca59..9707c8b64f 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,7 +40,7 @@ todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注 Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 -键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 +键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,由 Host settings 支撑的 `ui-conversation.busyEnter` General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;本地 settings 提供方将其存入 `$DSH_HOME/settings.yaml`,因此该选择会跟随同一个用户 home 跨越 Web 端口。Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index ff4e74da5e..6503f3cbd6 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", - "description": "Conversation domain: skeleton (header/tabs/composer), chat view, ctx.toolviews registry, minimal details panel", + "description": "Conversation domain: shell, chat and tool views, input policy with Host-backed busy-Enter preference, and details panel", "version": "0.0.1", "private": true, "type": "module", @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-layout" @@ -36,9 +37,12 @@ }, "license": "BSD-3-Clause", "dependencies": { - "clsx": "^2.0.0" + "@deepseek-ai/dsh-settings": "workspace:^", + "clsx": "^2.0.0", + "schemastery": "^3.18.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-primitives": "^0.0.1", @@ -50,6 +54,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6bc9068cfc..8f61c30e01 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,7 +1,7 @@ /** Registers the conversation components, shared store, and service callbacks. */ import type { Context } from 'cordis' import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSettingsPreference, type ISessions, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -36,6 +36,9 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { en, NS, zh, type ConversationKey } from './locales.ts' +import { + BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, isBusyEnterBehavior, +} from '../submission-settings.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -45,7 +48,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Services required by the conversation plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] +export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection'] // Static no-session sources for the composer-bar hooks compartment: module // constants so the render side's per-source hook cache (observableHook) keeps @@ -97,6 +100,13 @@ export function apply(ctx: Context): void { // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() const submissionPolicy = new ComposerSubmissionPolicy() + const preference = bindSettingsPreference(ctx, { + namespace: CONVERSATION_SETTINGS_NAMESPACE, + field: BUSY_ENTER_FIELD, + decode: value => isBusyEnterBehavior(value) ? value : undefined, + sync: (behavior) => { submissionPolicy.syncPreference(behavior) }, + }) + submissionPolicy.bindPersistence((behavior) => { void preference.persist(behavior) }) ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', diff --git a/packages/client/ui-conversation/src/client/contract/composer-submission.ts b/packages/client/ui-conversation/src/client/contract/composer-submission.ts index c5bcdc7826..23d9df94c1 100644 --- a/packages/client/ui-conversation/src/client/contract/composer-submission.ts +++ b/packages/client/ui-conversation/src/client/contract/composer-submission.ts @@ -1,10 +1,11 @@ /** Composer submission vocabulary shared by the input and settings domains. */ -/** Delivery mode requested for one ordinary composer message. */ -export type InputSubmitMode = 'queue' | 'steer' +import type { BusyEnterBehavior } from '../../submission-settings.ts' -/** Configurable meaning of plain Enter while the addressed agent is busy. */ -export type BusyEnterBehavior = InputSubmitMode +export type { BusyEnterBehavior } from '../../submission-settings.ts' + +/** Delivery mode requested for one ordinary composer message. */ +export type InputSubmitMode = BusyEnterBehavior /** Keyboard gesture whose delivery mode the submission policy resolves. */ export type ComposerSubmitGesture = 'enter' | 'accelerated' diff --git a/packages/client/ui-conversation/src/client/input/submission-policy.ts b/packages/client/ui-conversation/src/client/input/submission-policy.ts index 6ef87e42c8..968406972c 100644 --- a/packages/client/ui-conversation/src/client/input/submission-policy.ts +++ b/packages/client/ui-conversation/src/client/input/submission-policy.ts @@ -1,5 +1,5 @@ /** - * Browser-local Composer submission policy. It owns the persisted busy-Enter + * Composer submission policy. It owns the live busy-Enter * preference and resolves keyboard gestures into queue/steer delivery modes; * Host and Agent keep the actual delivery-window authority. */ @@ -7,12 +7,9 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client import type { BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode, } from '../contract/composer-submission.ts' +import { DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts' -/** localStorage key holding the busy-Enter preference. */ -export const BUSY_ENTER_STORAGE_KEY = 'dsh.conversation.busyEnter' - -/** Default preserves Enter-as-Queue for running conversations. */ -export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue' +export { DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts' /** * Persisted policy used by both the composer inject face and its Settings row. @@ -21,7 +18,21 @@ export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue' */ export class ComposerSubmissionPolicy { /** Reactive preference source for the Settings row. */ - readonly busyEnter: SnapshotStore = createSnapshotStore(restoreBusyEnter()) + readonly busyEnter: SnapshotStore = createSnapshotStore(DEFAULT_BUSY_ENTER_BEHAVIOR) + private persist: (behavior: BusyEnterBehavior) => void + + /** @param persist - durable write callback for explicit behavior changes. */ + constructor(persist: (behavior: BusyEnterBehavior) => void = () => {}) { + this.persist = persist + } + + /** + * Bind the owning plugin's durable writer before the policy is exposed. + * @param persist - callback accepting explicit behavior changes. + */ + bindPersistence(persist: (behavior: BusyEnterBehavior) => void): void { + this.persist = persist + } /** * Resolve one keyboard gesture without changing state. @@ -42,36 +53,21 @@ export class ComposerSubmissionPolicy { } /** - * Change and persist the plain-Enter behavior used during busy state. + * Change the plain-Enter behavior used during busy state. * @param behavior - Queue or Steer. */ setBusyEnter(behavior: BusyEnterBehavior): void { if (this.busyEnter.getSnapshot() === behavior) return this.busyEnter.set(behavior) - persistBusyEnter(behavior) + this.persist(behavior) } -} -/** Restore a valid preference; unavailable or corrupt storage uses Queue. */ -function restoreBusyEnter(): BusyEnterBehavior { - if (typeof localStorage === 'undefined') return DEFAULT_BUSY_ENTER_BEHAVIOR - let stored: string | null - try { - stored = localStorage.getItem(BUSY_ENTER_STORAGE_KEY) - } catch { - // Storage access can fail in privacy modes; the default remains usable. - return DEFAULT_BUSY_ENTER_BEHAVIOR - } - if (stored === 'queue' || stored === 'steer') return stored - return DEFAULT_BUSY_ENTER_BEHAVIOR -} - -/** Persist a preference when browser storage is available. */ -function persistBusyEnter(behavior: BusyEnterBehavior): void { - if (typeof localStorage === 'undefined') return - try { - localStorage.setItem(BUSY_ENTER_STORAGE_KEY, behavior) - } catch { - // A storage failure makes the preference session-only; input stays usable. + /** + * Apply a Host preference without writing it back. + * @param behavior - validated behavior from settings. + */ + syncPreference(behavior: BusyEnterBehavior): void { + if (this.busyEnter.getSnapshot() === behavior) return + this.busyEnter.set(behavior) } } diff --git a/packages/client/ui-conversation/src/index.ts b/packages/client/ui-conversation/src/index.ts index 142d3853e3..2377c8a73f 100644 --- a/packages/client/ui-conversation/src/index.ts +++ b/packages/client/ui-conversation/src/index.ts @@ -1,4 +1,35 @@ -/** Host loader entry for the browser-only conversation plugin. */ +/** Host registration for browser conversation preferences. */ -/** Provides no host-side behavior. */ -export function apply(): void {} +import type { Context } from 'cordis' +import z from 'schemastery' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, + DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, +} from './submission-settings.ts' + +export { + BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, + DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, +} from './submission-settings.ts' + +interface ConversationSettings { + busyEnter: BusyEnterBehavior +} + +const ConversationSettingsSchema: z = z.object({ + [BUSY_ENTER_FIELD]: z.union([...BUSY_ENTER_BEHAVIORS]).default(DEFAULT_BUSY_ENTER_BEHAVIOR), +}) + +/** + * Register the durable conversation section when a settings provider exists. + * @param ctx - Host context whose optional settings service owns the section. + */ +export function apply(ctx: Context): void { + ctx.inject(['settings'], (settingsCtx) => { + settingsCtx.settings.register( + settingsNamespace(CONVERSATION_SETTINGS_NAMESPACE), + ConversationSettingsSchema, + ) + }) +} diff --git a/packages/client/ui-conversation/src/submission-settings.ts b/packages/client/ui-conversation/src/submission-settings.ts new file mode 100644 index 0000000000..a1ba6e082c --- /dev/null +++ b/packages/client/ui-conversation/src/submission-settings.ts @@ -0,0 +1,25 @@ +/** Busy-Enter preference stored in the Host user-settings document. */ + +/** Settings namespace owned by the conversation plugin. */ +export const CONVERSATION_SETTINGS_NAMESPACE = 'ui-conversation' + +/** Field carrying the delivery mode for plain Enter while an agent is busy. */ +export const BUSY_ENTER_FIELD = 'busyEnter' + +/** Busy-Enter behaviors accepted at settings and input boundaries. */ +export const BUSY_ENTER_BEHAVIORS = ['queue', 'steer'] as const + +/** Configurable meaning of plain Enter while the addressed agent is busy. */ +export type BusyEnterBehavior = typeof BUSY_ENTER_BEHAVIORS[number] + +/** Default preserves Enter-as-Queue for running conversations. */ +export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue' + +/** + * Narrow one settings-wire value to a busy-Enter behavior. + * @param value - value crossing the settings boundary. + * @returns whether the value names a supported behavior. + */ +export function isBusyEnterBehavior(value: unknown): value is BusyEnterBehavior { + return BUSY_ENTER_BEHAVIORS.some(behavior => behavior === value) +} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 49682fea52..6868422eb2 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -47,6 +47,7 @@ function sessionFakeFor() { async function bench() { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) const sessionFake = sessionFakeFor() await runtime.sessions.add({ id: ROOT, diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 16163065eb..d6c8a8d106 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -96,6 +96,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -183,6 +184,7 @@ describe('terminal card assembly', () => { describe('resident composer', () => { it('renders the locked view state while no session exists at all', async () => { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -201,6 +203,7 @@ describe('resident composer', () => { it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -270,6 +273,7 @@ describe('resident composer', () => { describe('prompt rejection through the assembled composer', () => { it('renders the promptError alert strip and keeps the draft in the machine', async () => { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index df8fff6719..1a906235c8 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -24,6 +24,7 @@ const CHILD = 'child-1' as SessionId async function bench() { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false }) await runtime.sessions.add( { id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 8c4af6a921..88dc16a838 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -137,6 +137,7 @@ async function bench(snapshot: ConversationSnapshot) { } ctx.provide('workspaces', workspaces) ctx.provide('layout', layout) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) const locale = new LocaleService(ctx) ctx.provide('locale', locale) slots.installLocale(locale) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index e2319157ea..12e4011964 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -62,6 +62,7 @@ const LAYOUT_CHILDREN = { */ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } runtime.provide('layout', layout) const locale = new LocaleService(runtime.ctx) @@ -193,6 +194,7 @@ describe('keyed toolview hole through the real machinery', () => { describe('registrant declaration injection', () => { it('runs the plugin before ui-conversation and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index c92e43db6c..6f9f91da73 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -1,9 +1,10 @@ // @vitest-environment jsdom // Branch tails the acceptance specs do not reach: ToolRow stopped-state dot, -// bash sample state dots, the node-half empty apply, and AssistantMarkdown +// bash sample state dots, the node-half optional settings registration, and AssistantMarkdown // reasoning/unknown block arms. import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' import { cleanup, render } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' @@ -25,8 +26,8 @@ const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) afterEach(cleanup) describe('tails', () => { - it('node-half apply is an intentional no-op', () => { - expect(() => { nodeApply() }).not.toThrow() + it('node-half apply tolerates a Host without settings', () => { + expect(() => { nodeApply(new Context()) }).not.toThrow() }) it('ToolRow stopped state renders the warning dot in the leading slot', () => { diff --git a/packages/client/ui-conversation/tests/host.spec.ts b/packages/client/ui-conversation/tests/host.spec.ts new file mode 100644 index 0000000000..bb16273d64 --- /dev/null +++ b/packages/client/ui-conversation/tests/host.spec.ts @@ -0,0 +1,37 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { + CONVERSATION_SETTINGS_NAMESPACE, DEFAULT_BUSY_ENTER_BEHAVIOR, apply, +} from '@deepseek-ai/dsh-client-ui-conversation' +import { isBusyEnterBehavior } from '../src/submission-settings.ts' + +class MemorySettings extends Settings { + readonly writable = true + protected load(): Promise> { return Promise.resolve({}) } + protected persist(_ns: SettingsNamespace, _section: Record): Promise { + return Promise.resolve() + } +} + +describe('ui-conversation host', () => { + it('narrows settings-wire values to the supported behavior pair', () => { + expect(isBusyEnterBehavior('queue')).toBe(true) + expect(isBusyEnterBehavior('steer')).toBe(true) + expect(isBusyEnterBehavior('later')).toBe(false) + }) + + it('registers, validates, and disposes the durable busy-Enter preference', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings).await() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const ns = settingsNamespace(CONVERSATION_SETTINGS_NAMESPACE) + expect(ctx.settings.get(ns)).toEqual({ busyEnter: DEFAULT_BUSY_ENTER_BEHAVIOR }) + await ctx.settings.update(ns, { busyEnter: 'steer' }) + expect(ctx.settings.get(ns)).toEqual({ busyEnter: 'steer' }) + await expect(ctx.settings.update(ns, { busyEnter: 'invalid' })).rejects.toThrow() + await fiber.dispose() + expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns) + }) +}) diff --git a/packages/client/ui-conversation/tests/submission-policy.spec.ts b/packages/client/ui-conversation/tests/submission-policy.spec.ts index 5b892982ab..6519e9f9d3 100644 --- a/packages/client/ui-conversation/tests/submission-policy.spec.ts +++ b/packages/client/ui-conversation/tests/submission-policy.spec.ts @@ -1,14 +1,9 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { - BUSY_ENTER_STORAGE_KEY, ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR, + ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR, } from '../src/client/input/submission-policy.ts' -afterEach(() => { - vi.unstubAllGlobals() - localStorage.clear() -}) - describe('ComposerSubmissionPolicy', () => { it('defaults to Queue and only applies the preference while running', () => { const policy = new ComposerSubmissionPolicy() @@ -21,6 +16,8 @@ describe('ComposerSubmissionPolicy', () => { expect(policy.resolve(true, 'accelerated', false)).toBe('queue') const changed = vi.fn() + const persist = vi.fn() + policy.bindPersistence(persist) policy.busyEnter.subscribe(changed) policy.setBusyEnter('steer') expect(changed).toHaveBeenCalledTimes(1) @@ -28,40 +25,25 @@ describe('ComposerSubmissionPolicy', () => { expect(policy.resolve(true, 'accelerated', true)).toBe('queue') expect(policy.resolve(false, 'enter', true)).toBe('queue') expect(policy.resolve(false, 'accelerated', true)).toBe('queue') - expect(localStorage.getItem(BUSY_ENTER_STORAGE_KEY)).toBe('steer') + expect(persist).toHaveBeenCalledWith('steer') }) - it('restores a valid preference and leaves an identical write untouched', () => { - localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'steer') - const write = vi.spyOn(Storage.prototype, 'setItem') - const policy = new ComposerSubmissionPolicy() + it('syncs a Host preference without writing it back and leaves an identical write untouched', () => { + const persist = vi.fn() + const policy = new ComposerSubmissionPolicy(persist) + policy.syncPreference('steer') expect(policy.busyEnter.getSnapshot()).toBe('steer') policy.setBusyEnter('steer') - expect(write).not.toHaveBeenCalled() - write.mockRestore() + expect(persist).not.toHaveBeenCalled() }) - it('uses Queue for invalid, unavailable, or unreadable storage', () => { - localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'invalid') - expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue') - - vi.stubGlobal('localStorage', undefined) - expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue') - - vi.stubGlobal('localStorage', { - getItem: () => { throw new Error('blocked') }, - setItem: vi.fn(), - }) - expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue') - }) - - it('keeps the in-memory preference when persistence throws', () => { - vi.stubGlobal('localStorage', { - getItem: () => null, - setItem: () => { throw new Error('quota') }, - }) + it('publishes the in-memory preference before calling the durable writer', () => { const policy = new ComposerSubmissionPolicy() + const persist = vi.fn(() => { + expect(policy.busyEnter.getSnapshot()).toBe('steer') + }) + policy.bindPersistence(persist) policy.setBusyEnter('steer') - expect(policy.busyEnter.getSnapshot()).toBe('steer') + expect(persist).toHaveBeenCalledOnce() }) }) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 04b265bdd5..f2b78f7dfe 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../connection" + }, { "path": "../ui-slots" }, @@ -47,6 +50,9 @@ { "path": "../locale" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" }, diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 09221e1e94..30d956fc39 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -18,7 +18,7 @@ import { import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' -import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' +import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { SubagentCatalogAction, type SubagentCatalogInjected, } from '../src/client/SubagentCatalogAction.tsx' @@ -84,8 +84,9 @@ async function fullBench(sessions: SessionSummary[]) { const face = sessionsWith(sessions) ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) ctx.provide('sessions', face) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await provideSlotFaces(ctx) - await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() await ctx.plugin({ inject: [...inject], apply }).await() return { source: captured!, face, ctx } } @@ -119,8 +120,9 @@ describe('apply', () => { const ctx = new Context() await ctx.plugin(SlashService).await() ctx.provide('sessions', sessionsWith(FAMILY)) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await provideSlotFaces(ctx) - await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const slash = ctx.get('slash') as SlashService diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 04fd1e81c2..84438c92a4 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/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-theme/README.md -README.md: 32868bcac4313a3badfe92dbf41c84e793f09709 -README.zh.md: a38765b8004826133875c38deeb66128d52ec986 +README.md: b79eac0d7777ac7af9b6a8960dc4d9797b41513d +README.zh.md: c57ccbdb8fdfb735b3a5d0d66f3538dd01966ada diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 32868bcac4..b79eac0d77 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser loads `ui-theme.preference` before providing the service and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema. Contract: api-contracts v3 §8; the [Host-backed preference decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md) owns the persistence boundary. +Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser provides the service immediately with `system`, then loads `ui-theme.preference` in the background and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. Contract: api-contracts v3 §8; the [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. `src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index a38765b800..c57ccbdb8f 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会在提供该服务前加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema。契约:api-contracts v3 §8;该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md)拥有。 +主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。契约:api-contracts v3 §8;该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 `src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。 diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 7635da17b8..a4f9a78d37 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -44,7 +44,6 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 497f4a22f1..05c8a741af 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -8,28 +8,24 @@ * settings General section — the theme feature owns its own settings surface. */ import type { Context } from 'cordis' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSettingsPreference, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' import { AppearanceRow } from './AppearanceRow.tsx' import { createAppearanceRowStore } from './settings-store.ts' -import { ThemeSettingsController } from './theme-settings.ts' import { en, zh, type ThemeKey } from './locales.ts' import { - DEFAULT_PREFERENCE, isThemePreference, THEME_SETTINGS_NAMESPACE, + DEFAULT_PREFERENCE, isThemePreference, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, type ThemePreference, } from '../theme-settings.ts' export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' export type { AppearanceRowState } from './settings-store.ts' -export type { ThemePreferenceTarget } from './theme-settings.ts' -export { ThemeSettingsController } from './theme-settings.ts' export type { ThemeKey } from './locales.ts' export { - DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, type ThemePreference, } from '../theme-settings.ts' @@ -196,7 +192,6 @@ export class ThemeService { this.themes = this.themes.filter(t => t.id !== definition.id) if (this.preference === definition.id) { this.preference = DEFAULT_PREFERENCE - this.persist(this.preference) } this.publish() } @@ -235,33 +230,17 @@ export const inject = ['slots', 'locale', 'connection'] * slot (a feature owns its settings surface). * @param ctx - client cordis context. */ -export async function apply(ctx: ClientContext): Promise { - const connection = ctx.get('connection') as ConnectionHandle +export function apply(ctx: ClientContext): void { const theme = new ThemeService(ctx) - const controller = new ThemeSettingsController( - connection.api, - theme, - connection.isLoopback ? 'host' : 'memory', - ) + const controller = bindSettingsPreference(ctx, { + namespace: THEME_SETTINGS_NAMESPACE, + field: THEME_PREFERENCE_FIELD, + decode: value => isThemePreference(value) ? value : undefined, + sync: (preference) => { theme.syncPreference(preference) }, + }) theme.bindPersistence((preference) => { void controller.persist(preference) }) - await controller.load() ctx.provide('theme', theme) - ctx.effect(() => { - const refresh = (ns?: string): void => { - if (ns !== undefined && ns !== THEME_SETTINGS_NAMESPACE) return - void controller.load() - } - const disposers = [ - ctx.on('settings/changed', refresh), - ctx.on('connection/reset', () => { refresh() }), - ] - return () => { - controller.dispose() - for (const dispose of disposers) dispose() - } - }, 'ui-theme: settings invalidations') - ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') const store = createAppearanceRowStore() diff --git a/packages/client/ui-theme/src/client/theme-settings.ts b/packages/client/ui-theme/src/client/theme-settings.ts deleted file mode 100644 index 66b332313b..0000000000 --- a/packages/client/ui-theme/src/client/theme-settings.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** Host-backed persistence controller for the browser theme preference. */ - -import type { - IApiClient, SettingsNamespaceView, -} from '@deepseek-ai/dsh-client-connection/client' -import { - THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, isThemePreference, - type ThemePreference, -} from '../theme-settings.ts' - -/** Preference target implemented by {@link ThemeService}. */ -export interface ThemePreferenceTarget { - /** - * Apply a Host value without writing it back. - * @param preference - validated durable preference. - */ - syncPreference(preference: ThemePreference): void -} - -function preferenceOf(view: SettingsNamespaceView): ThemePreference | undefined { - if (typeof view.value !== 'object' || view.value === null) return undefined - const preference = (view.value as Record)[THEME_PREFERENCE_FIELD] - return isThemePreference(preference) ? preference : undefined -} - -/** Coordinates startup reads, ordered writes, and pushed invalidations. */ -export class ThemeSettingsController { - private generation = 0 - private writeTail: Promise = Promise.resolve() - - /** - * @param api - settings wire face. - * @param target - live theme service receiving durable values. - * @param persistence - remote browsers stay process-local because the settings API is loopback-only. - */ - constructor( - private readonly api: Pick, - private readonly target: ThemePreferenceTarget, - private readonly persistence: 'host' | 'memory' = 'host', - ) {} - - /** - * Load the durable preference after earlier writes settle; the latest operation wins. - * @returns nothing; an unavailable or invalid descriptor leaves the last good value active. - */ - async load(): Promise { - const generation = ++this.generation - if (this.persistence === 'memory') return - await this.writeTail - if (generation !== this.generation) return - let response: Awaited['settings']['describe']>> - try { - response = await this.api.settings.describe({}) - } catch (_settingsReadFailure) { - // A transport failure leaves the last good in-process theme active. A - // connection/reset or settings/changed notification retries the read. - return - } - if (!response.result.ok || generation !== this.generation) return - const view = response.result.value.namespaces.find( - candidate => candidate.ns === THEME_SETTINGS_NAMESPACE, - ) - if (view === undefined) return - const preference = preferenceOf(view) - if (preference !== undefined) this.target.syncPreference(preference) - } - - /** - * Persist one user selection. Writes are serialized so rapid picks land in - * gesture order; a rejected latest write reloads the durable value. - * @param preference - selected built-in preference. - * @returns nothing after the write or recovery read settles. - */ - async persist(preference: ThemePreference): Promise { - const generation = ++this.generation - if (this.persistence === 'memory') return - const write = this.writeTail.then(async () => { - const response = await this.api.settings.mutate({ - ns: THEME_SETTINGS_NAMESPACE, - ops: [{ op: 'set', path: [THEME_PREFERENCE_FIELD], value: preference }], - }) - if (!response.result.ok) throw new Error(response.result.error.message) - if (generation === this.generation) { - const accepted = preferenceOf(response.result.value) - if (accepted !== undefined) this.target.syncPreference(accepted) - } - }) - this.writeTail = write.catch(() => {}) - try { - await write - } catch { - if (generation === this.generation) await this.load() - } - } - - /** Prevent in-flight reads and writes from publishing after plugin disposal. */ - dispose(): void { - this.generation += 1 - } -} diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index 5f746d6d83..32d3689950 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -4,12 +4,12 @@ import type { Context } from 'cordis' import z from 'schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { - DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, type ThemePreference, } from './theme-settings.ts' export { - DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, type ThemePreference, } from './theme-settings.ts' @@ -18,7 +18,7 @@ interface ThemeSettings { } const ThemeSettingsSchema: z = z.object({ - [THEME_PREFERENCE_FIELD]: z.union(['light', 'dark', 'system']).default(DEFAULT_PREFERENCE), + [THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE), }) /** diff --git a/packages/client/ui-theme/src/theme-settings.ts b/packages/client/ui-theme/src/theme-settings.ts index e93b3c56e0..ca06ec28a7 100644 --- a/packages/client/ui-theme/src/theme-settings.ts +++ b/packages/client/ui-theme/src/theme-settings.ts @@ -1,5 +1,8 @@ /** Theme preferences stored in the Host user-settings document. */ +/** Built-in preferences accepted at the registry and settings boundaries. */ +export const THEME_PREFERENCES = ['light', 'dark', 'system'] as const + /** Settings namespace owned by the theme plugin. */ export const THEME_SETTINGS_NAMESPACE = 'ui-theme' @@ -7,7 +10,7 @@ export const THEME_SETTINGS_NAMESPACE = 'ui-theme' export const THEME_PREFERENCE_FIELD = 'preference' /** Theme preference persisted by the product Appearance row. */ -export type ThemePreference = 'light' | 'dark' | 'system' +export type ThemePreference = typeof THEME_PREFERENCES[number] /** Default preference when the user-settings document has no override. */ export const DEFAULT_PREFERENCE: ThemePreference = 'system' @@ -18,5 +21,5 @@ export const DEFAULT_PREFERENCE: ThemePreference = 'system' * @returns whether the value is a built-in preference. */ export function isThemePreference(value: unknown): value is ThemePreference { - return value === 'light' || value === 'dark' || value === 'system' + return THEME_PREFERENCES.some(preference => preference === value) } diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index 350ea0525a..d134340560 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -19,6 +19,12 @@ usePinnedBrowserLanguages('zh-CN') const SLOT = 'settings.general.item' +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotsService).await() @@ -122,7 +128,7 @@ describe('ui-theme apply', () => { declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const theme = b.ctx.get('theme') as ThemeService - expect(theme.getTheme().preference).toBe('dark') + await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) b.ctx.emit('settings/changed', 'unrelated') expect(b.describe).toHaveBeenCalledOnce() b.setHostPreference('light') @@ -142,6 +148,30 @@ describe('ui-theme apply', () => { expect(remote.mutate).not.toHaveBeenCalled() }) + it('activates before a slow initial settings read and converges when it settles', async () => { + const b = await bench() + b.setHostPreference('dark') + const describe = b.describe.getMockImplementation()! + const pending = deferred>>() + b.describe.mockImplementationOnce(() => pending.promise) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const theme = b.ctx.get('theme') as ThemeService + expect(theme.getTheme().preference).toBe('system') + pending.resolve(await describe()) + await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) + await fiber.dispose() + }) + + it('ignores an invalid preference crossing the settings wire', async () => { + const b = await bench() + b.setHostPreference('sepia') + await b.ctx.plugin({ inject: [...inject], apply }).await() + const theme = b.ctx.get('theme') as ThemeService + await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledOnce() }) + expect(theme.getTheme().preference).toBe('system') + }) + it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { const b = await bench() const host = declareItems(b.slots) diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.spec.ts index 42a2651099..c5eedc9dd7 100644 --- a/packages/client/ui-theme/tests/invariant.spec.ts +++ b/packages/client/ui-theme/tests/invariant.spec.ts @@ -4,7 +4,7 @@ import { Context } from 'cordis' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme' import { apply as clientApply, inject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant' -import { apply as localeApply } from '@deepseek-ai/dsh-client-locale/client' +import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -26,7 +26,6 @@ describe('invariant companion', () => { expect(inject).toEqual(['slots', 'locale', 'connection']) const ctx = new Context() new SlotsService(ctx) - await ctx.plugin({ inject: ['slots'], apply: localeApply }).await() ctx.provide('connection', { api: { settings: { describe: () => Promise.resolve({ rpcId: 'theme-invariant' as never, @@ -34,6 +33,7 @@ describe('invariant companion', () => { }) } }, isLoopback: true, } as never) + await ctx.plugin({ inject: localeInject, apply: localeApply }).await() await ctx.plugin({ inject, apply: clientApply }).await() expect(ctx.get('theme')).toBeInstanceOf(ThemeService) }) diff --git a/packages/client/ui-theme/tests/theme-settings.spec.ts b/packages/client/ui-theme/tests/theme-settings.spec.ts deleted file mode 100644 index b2b921a4c2..0000000000 --- a/packages/client/ui-theme/tests/theme-settings.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' -import { - THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, ThemeSettingsController, - type ThemePreference, -} from '@deepseek-ai/dsh-client-ui-theme/client' - -let rpc = 0 - -function ok(value: T): RpcResponse { - return { rpcId: `theme-${rpc++}` as never, result: { ok: true, value } } -} - -function view(preference: unknown = 'system'): SettingsNamespaceView { - return { - ns: THEME_SETTINGS_NAMESPACE, - schema: {}, - value: { [THEME_PREFERENCE_FIELD]: preference }, - applies: 'live', - secrets: [], - revision: 0, - } -} - -function described(preference: unknown = 'system') { - return ok({ writable: true, hasDocument: true, namespaces: [view(preference)] }) -} - -function deferred() { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((res, rej) => { resolve = res; reject = rej }) - return { promise, resolve, reject } -} - -function target() { - const values: ThemePreference[] = [] - return { values, syncPreference: (preference: ThemePreference) => { values.push(preference) } } -} - -describe('ThemeSettingsController', () => { - it('loads a valid Host value and ignores unavailable or malformed namespaces', async () => { - const receiver = target() - const describe = vi.fn() - .mockResolvedValueOnce(described('dark')) - .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) - .mockResolvedValueOnce(described('sepia')) - .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [{ ...view(), value: null }] })) - .mockResolvedValueOnce({ - rpcId: 'failed' as never, - result: { ok: false as const, error: { code: 'internal' as const, message: 'offline', details: {} } }, - }) - .mockRejectedValueOnce(new Error('transport offline')) - const controller = new ThemeSettingsController({ settings: { describe } } as never, receiver) - for (let i = 0; i < 6; i++) await controller.load() - expect(receiver.values).toEqual(['dark']) - }) - - it('persists ordered rapid selections and publishes only the latest settlement', async () => { - const first = deferred>>() - const calls: string[] = [] - const mutate = vi.fn(async (request: { ops: { value: string }[] }) => { - const preference = request.ops[0]!.value - calls.push(preference) - if (preference === 'dark') return first.promise - return ok(view(preference)) - }) - const receiver = target() - const controller = new ThemeSettingsController({ settings: { mutate } } as never, receiver) - const dark = controller.persist('dark') - const light = controller.persist('light') - await Promise.resolve() - expect(calls).toEqual(['dark']) - first.resolve(ok(view('dark'))) - await Promise.all([dark, light]) - expect(calls).toEqual(['dark', 'light']) - expect(receiver.values).toEqual(['light']) - expect(mutate).toHaveBeenNthCalledWith(1, { - ns: THEME_SETTINGS_NAMESPACE, - ops: [{ op: 'set', path: [THEME_PREFERENCE_FIELD], value: 'dark' }], - }) - }) - - it('reloads after a rejected latest write and contains stale reads and disposal', async () => { - const stale = deferred>() - const describe = vi.fn() - .mockImplementationOnce(() => stale.promise) - .mockResolvedValueOnce(described('system')) - const mutate = vi.fn().mockResolvedValue({ - rpcId: 'rejected' as never, - result: { ok: false as const, error: { code: 'settings-rejected' as const, message: 'disk full', details: {} } }, - }) - const receiver = target() - const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver) - const oldLoad = controller.load() - await vi.waitFor(() => { expect(describe).toHaveBeenCalledOnce() }) - await controller.persist('dark') - stale.resolve(described('light')) - await oldLoad - expect(receiver.values).toEqual(['system']) - - const disposedRead = deferred>() - describe.mockImplementationOnce(() => disposedRead.promise) - const pending = controller.load() - controller.dispose() - disposedRead.resolve(described('dark')) - await pending - expect(receiver.values).toEqual(['system']) - }) - - it('keeps remote-browser persistence in memory without calling Host settings', async () => { - const describe = vi.fn() - const mutate = vi.fn() - const receiver = target() - const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver, 'memory') - await controller.load() - await controller.persist('dark') - expect(describe).not.toHaveBeenCalled() - expect(mutate).not.toHaveBeenCalled() - expect(receiver.values).toEqual([]) - }) - - it('reloads after a thrown write and ignores a malformed success response', async () => { - const receiver = target() - const describe = vi.fn().mockResolvedValue(described('light')) - const mutate = vi.fn() - .mockRejectedValueOnce(new Error('offline')) - .mockResolvedValueOnce(ok(view('sepia'))) - const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver) - await controller.persist('dark') - await controller.persist('system') - expect(receiver.values).toEqual(['light']) - }) - - it('lets an explicit refresh supersede a stale rejected write', async () => { - const rejected = deferred() - const receiver = target() - const describe = vi.fn().mockResolvedValue(described('system')) - const mutate = vi.fn().mockReturnValue(rejected.promise) - const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver) - const write = controller.persist('dark') - await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) - const refresh = controller.load() - rejected.reject(new Error('stale rejection')) - await Promise.all([write, refresh]) - expect(receiver.values).toEqual(['system']) - expect(describe).toHaveBeenCalledOnce() - }) -}) diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index 68f0f3c7f8..f6d8a7ff62 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -71,8 +71,7 @@ describe('ThemeService', () => { expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark']) // Custom ids are in-process extension themes; only the built-in product // preferences cross the Host settings schema. - expect(persist).toHaveBeenCalledTimes(1) - expect(persist).toHaveBeenCalledWith('system') + expect(persist).not.toHaveBeenCalled() // register + set + dispose = three publishes; disposer is idempotent. expect(events.length).toBe(3) dispose() diff --git a/packages/client/ui-theme/tsconfig.json b/packages/client/ui-theme/tsconfig.json index 6b15b210d6..f3ca3240c2 100644 --- a/packages/client/ui-theme/tsconfig.json +++ b/packages/client/ui-theme/tsconfig.json @@ -8,9 +8,6 @@ "src" ], "references": [ - { - "path": "../connection" - }, { "path": "../locale" }, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 5035572ba4..f9ba0ce0a1 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: c1e818fa8ff52b10e722d9fd450073a6aede85da -README.zh.md: dfac19fa04d6b934c86733cb2a075017740ed37a +README.md: 2e7e50c2251a0cf0daa5821d210a34635acd57ea +README.zh.md: 2d2d732bbc1982f750991fc90de51b78c7ed1019 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index c1e818fa8f..2e7e50c225 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -36,7 +36,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `permission` and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission`, `ui-theme`, or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `locale`, `permission`, `ui-conversation`, `ui-theme`, or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index dfac19fa04..2d2d732bbc 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -36,7 +36,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission`、`ui-theme` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale`、`permission`、`ui-conversation` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`locale`、`permission`、`ui-conversation`、`ui-theme` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 80d4923dac..2b211f2b6f 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -74,7 +74,7 @@ import { openNativePath, openNativeTextFile } from './native-path-opener.ts' const DEFAULT_MAX_MESSAGES = 50 /** Non-model settings namespaces intentionally served to the Web client. */ -const WEB_SETTINGS_NAMESPACES = ['permission', 'ui-theme'] as const +const WEB_SETTINGS_NAMESPACES = ['locale', 'permission', 'ui-conversation', 'ui-theme'] as const /** Provider work budget: at most 100 calls and 2,000 inspected hits. */ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index cc16519f65..a803d2048f 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -308,8 +308,8 @@ describe('settings domain', () => { // The settings seam is general: any plugin may register a namespace for // its own configuration. The Web configuration plane remains opt-in, so a // future internal plugin cannot become remotely configurable just by - // registering; permission, theme, and the product onboarding namespace - // are the non-model namespaces intentionally admitted by this surface. + // registering; locale, permission, conversation, theme, and the product + // onboarding namespace are intentionally admitted by this surface. const ctx = await harness() ctx.settings.register(NS, AdapterConfig) ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() })) @@ -321,10 +321,18 @@ describe('settings domain', () => { ctx.settings.register(settingsNamespace('ui-theme'), z.object({ preference: z.union(['light', 'dark', 'system']).default('system'), })) + ctx.settings.register(settingsNamespace('locale'), z.object({ + preference: z.union(['zh', 'en']).required(false), + })) + ctx.settings.register(settingsNamespace('ui-conversation'), z.object({ + busyEnter: z.union(['queue', 'steer']).default('queue'), + })) const api = createApiProxy(ctx, DEFAULTS) const value = expectOk(await api.settings.describe(request({}))) - expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission', 'ui-theme']) + expect(value.namespaces.map(view => view.ns)).toEqual([ + 'llm-deepseek', 'permission', 'ui-theme', 'locale', 'ui-conversation', + ]) const permission = expectOk(await api.settings.mutate(request({ ns: 'permission', ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], @@ -335,6 +343,16 @@ describe('settings domain', () => { ops: [{ op: 'set', path: ['preference'], value: 'dark' }], }))) expect(theme.value).toEqual({ preference: 'dark' }) + const locale = expectOk(await api.settings.mutate(request({ + ns: 'locale', + ops: [{ op: 'set', path: ['preference'], value: 'en' }], + }))) + expect(locale.value).toEqual({ preference: 'en' }) + const conversation = expectOk(await api.settings.mutate(request({ + ns: 'ui-conversation', + ops: [{ op: 'set', path: ['busyEnter'], value: 'steer' }], + }))) + expect(conversation.value).toEqual({ busyEnter: 'steer' }) for (const response of [ await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 562e645956..0809ab5150 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1286,6 +1286,16 @@ importers: version: link:../../../vendor/cordis packages/client/locale: + dependencies: + '@deepseek-ai/dsh-client-connection': + specifier: ^0.0.1 + version: link:../connection + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ @@ -1480,10 +1490,19 @@ importers: packages/client/ui-conversation: dependencies: + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings clsx: specifier: ^2.0.0 version: 2.1.1 + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2142,6 +2161,9 @@ importers: packages/client/ui-theme: dependencies: + '@deepseek-ai/dsh-client-connection': + specifier: ^0.0.1 + version: link:../connection '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -2152,9 +2174,6 @@ importers: specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale diff --git a/vitest.config.ts b/vitest.config.ts index cd37feb300..84e273f0da 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -160,8 +160,13 @@ export default defineConfig({ 'packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx', 'packages/client/ui-workspace/src/client/WorkspacePicker.tsx', 'packages/client/web-react/src/*', - 'packages/client/runtime/src/*', - 'packages/client/ui-conversation/src/*', + // This isolated scalar-settings lifecycle has complete unit coverage; + // keep it out of the broader client-runtime GUI debt exemption. + 'packages/client/runtime/src/**/!(settings-preference).ts', + // Keep the browser conversation tree under its existing GUI debt + // exemption while gating the newly stateful Host half and vocabulary. + 'packages/client/ui-conversation/src/client/*', + 'packages/client/ui-conversation/src/invariant.ts', 'packages/client/ui-slots/src/*', 'packages/client/ui-layout/src/*', 'packages/client/web/src/*', From 1dd2252d16252c9cf84595574dc605fbcb79b288 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 16:49:36 +0800 Subject: [PATCH 044/229] docs: refresh module graph --- docs/module-graph.md | 132 ++++++++++++++++++++++--------------------- 1 file changed, 67 insertions(+), 65 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index f84255b203..7dc0976cea 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -320,10 +320,6 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants - pkg_client_locale --> pkg_client_runtime - pkg_client_locale --> pkg_client_ui_primitives - pkg_client_locale --> pkg_client_ui_slots - pkg_client_locale --> pkg_invariants pkg_client_test_runtime --> pkg_client_runtime pkg_client_test_runtime --> pkg_client_ui_slots pkg_client_test_runtime --> pkg_client_web_react @@ -378,6 +374,11 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_client_locale --> pkg_client_connection + pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_primitives + pkg_client_locale --> pkg_client_ui_slots + pkg_client_locale --> pkg_invariants pkg_client_ui_models --> pkg_client_connection pkg_client_ui_models --> pkg_client_runtime pkg_client_ui_models --> pkg_client_schema_form @@ -385,37 +386,6 @@ flowchart TD pkg_client_ui_models --> pkg_client_ui_slots pkg_client_ui_models --> pkg_client_web_react pkg_client_ui_models --> pkg_invariants - pkg_client_ui_question --> pkg_client_locale - pkg_client_ui_question --> pkg_invariants - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_primitives - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_slots - pkg_client_ui_settings_general --> pkg_client_web_react - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_locale - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_primitives - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_theme --> pkg_client_connection - pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime - pkg_client_ui_theme --> pkg_client_ui_primitives - pkg_client_ui_theme --> pkg_client_ui_slots - pkg_client_ui_theme --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants @@ -464,24 +434,41 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_client_web_react + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_slash --> pkg_client_locale + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_primitives + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_client_connection + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants pkg_lsp_local --> pkg_brand pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm @@ -567,6 +554,10 @@ flowchart TD pkg_headless --> pkg_host_webserver pkg_headless --> pkg_invariants pkg_headless --> pkg_session + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -574,10 +565,16 @@ flowchart TD pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants pkg_tmux_context --> pkg_session - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -669,6 +666,7 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval + pkg_client_ui_conversation --> pkg_client_connection pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -679,6 +677,10 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -1154,7 +1156,6 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`client-locale`](../packages/client/locale) | `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-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-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) | @@ -1173,13 +1174,8 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`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-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`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-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`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-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`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-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -1195,10 +1191,13 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`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-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`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-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`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-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1220,9 +1219,11 @@ flowchart TD | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | @@ -1242,8 +1243,9 @@ flowchart TD | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | From e9f6d82b12d39e33a76f9017ccc75ff9d387dcb1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 19:30:22 +0800 Subject: [PATCH 045/229] fix(locale): keep settings constants private --- packages/client/locale/src/client/index.ts | 4 +--- packages/client/locale/tests/apply.spec.ts | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index ac694b0bce..228b3a3375 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -29,9 +29,7 @@ export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageR export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts' export type { CommonKey } from '../locales/index.ts' -export { - LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, -} from '../locale-settings.ts' +export type { LocaleId } from '../locale-settings.ts' // The translate currency lives in ui-slots (the render machinery synthesizes // the seat); re-exported here so dictionary owners import one package. diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index 2bd424a974..3f1f6b2c0a 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -5,9 +5,10 @@ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { - apply, inject, LOCALE_SETTINGS_NAMESPACE, SETTINGS_NS, + apply, inject, SETTINGS_NS, } from '@deepseek-ai/dsh-client-locale/client' import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { LOCALE_SETTINGS_NAMESPACE } from '../src/locale-settings.ts' import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { createLanguageRowStore } from '../src/client/settings-store.ts' From 638c9e4bd7d89363b345f694710aefd79418f1ff Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 23:25:42 +0800 Subject: [PATCH 046/229] refactor(client): replace the per-field settings preference controller with a namespace settings scope bindSettingsScope mirrors the Host-side settings owner seam in the browser: one scope per namespace publishes a snapshot store (status, section value, revision, writability, host/memory mode), validates sections against the namespace's serialized wire schema via dsh-client-schema-form, and keeps the controller's listener-before-read, revisioned serialized writes, latest-wins publication, conflict recovery, and disposal quiescence. Theme, locale, and busy-Enter services now take the scope as a constructor collaborator, which removes the bindPersistence/syncPreference two-phase callback pair and the defaulted no-op persist writers; hand-written wire guards fall away in favor of the registered schema. test-runtime gains a stubSettingsScope double. --- ...8-06-host-backed-web-preferences.i18n.yaml | 4 +- .../2026-08-06-host-backed-web-preferences.md | 10 +- ...26-08-06-host-backed-web-preferences.zh.md | 10 +- packages/client/locale/src/client/index.ts | 64 ++-- packages/client/locale/src/index.ts | 12 +- packages/client/locale/src/locale-settings.ts | 11 +- packages/client/locale/tests/apply.spec.ts | 3 +- packages/client/locale/tests/locale.spec.ts | 61 ++- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- packages/client/runtime/package.json | 4 +- packages/client/runtime/src/client/index.ts | 4 +- .../runtime/src/client/settings-preference.ts | 160 -------- .../runtime/src/client/settings-scope.ts | 261 +++++++++++++ .../runtime/tests/settings-preference.spec.ts | 237 ------------ .../runtime/tests/settings-scope.spec.ts | 352 ++++++++++++++++++ packages/client/runtime/tsconfig.json | 3 + packages/client/test-runtime/README.i18n.yaml | 4 +- packages/client/test-runtime/README.md | 2 +- packages/client/test-runtime/README.zh.md | 2 +- packages/client/test-runtime/src/index.ts | 2 + .../client/test-runtime/src/settings-scope.ts | 48 +++ .../ui-conversation/src/client/apply.ts | 17 +- .../src/client/input/submission-policy.ts | 46 ++- packages/client/ui-conversation/src/index.ts | 11 +- .../src/submission-settings.ts | 11 +- .../client/ui-conversation/tests/host.spec.ts | 7 - .../tests/submission-policy.spec.ts | 49 ++- packages/client/ui-theme/src/client/index.ts | 58 ++- packages/client/ui-theme/src/index.ts | 11 +- packages/client/ui-theme/src/invariant.ts | 4 +- .../client/ui-theme/src/theme-settings.ts | 6 + packages/client/ui-theme/tests/apply.spec.ts | 3 +- packages/client/ui-theme/tests/theme.spec.ts | 48 ++- pnpm-lock.yaml | 6 + vitest.config.ts | 4 +- 37 files changed, 926 insertions(+), 617 deletions(-) delete mode 100644 packages/client/runtime/src/client/settings-preference.ts create mode 100644 packages/client/runtime/src/client/settings-scope.ts delete mode 100644 packages/client/runtime/tests/settings-preference.spec.ts create mode 100644 packages/client/runtime/tests/settings-scope.spec.ts create mode 100644 packages/client/test-runtime/src/settings-scope.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml index 13dd2d5672..00884185ca 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-host-backed-web-preferences.md -2026-08-06-host-backed-web-preferences.md: ee1c0aea360eb1a4b34eadc86c5c3091abc6663a -2026-08-06-host-backed-web-preferences.zh.md: 376e670f9af39f43783a1447498ca2d4c65a49cd +2026-08-06-host-backed-web-preferences.md: d56a8d2e330b214a1922997e3cc7165fd0fb31e4 +2026-08-06-host-backed-web-preferences.zh.md: 593646fe0845c20fb09cb7d115e6fa558226506e diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md index ee1c0aea36..d56a8d2e33 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md @@ -14,9 +14,9 @@ The first theme implementation moved only Appearance to Host settings but awaite The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy explicitly exposes all three namespaces beside the other Web settings; registration alone never crosses that configuration boundary. -The client runtime provides one `bindSettingsPreference` lifecycle for scalar preferences. It installs `settings/changed` and `connection/reset` listeners before starting a background initial read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap. Domain services publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then accept a validated Host value without writing it back. +The client runtime provides one `bindSettingsScope` lifecycle per namespace — the browser mirror of the Host-side settings owner seam. It installs `settings/changed` and `connection/reset` listeners before starting a background initial read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap, and it publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through dsh-client-schema-form, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local. -User changes update the live service synchronously and queue a `settings.mutate` path operation. The controller serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence. +User changes update the live service synchronously and queue a `settings.mutate` path operation through `scope.set`. The scope serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence. Remote browsers cannot call the loopback-only configuration API, so their preferences remain process-local. Dynamic third-party theme ids remain in-process extensions outside the built-in Host schema; removing one resets the live registry without replacing the last durable built-in preference. @@ -28,7 +28,9 @@ Remote browsers cannot call the loopback-only configuration API, so their prefer **Await the initial read to avoid a provisional render.** Configuration availability is not a prerequisite for drawing the page. A background read may cause one live convergence, but it keeps failure isolated and preserves the existing browser/system/default fallbacks. -**Give every domain its own settings controller.** The concurrency, revision, failure, invalidation, and disposal rules are identical; copying them already produced lifecycle drift in the theme implementation. Domain-owned schemas and decoders keep product policy out of the shared runtime. +**Give every domain its own settings controller.** The concurrency, revision, failure, invalidation, and disposal rules are identical; copying them already produced lifecycle drift in the theme implementation. Domain-owned schemas keep product policy out of the shared runtime. + +**A per-field preference controller with paired sync/persist callbacks.** The first shared lifecycle synchronized one scalar field through a domain `sync` callback while the service wrote back through an injected `persist` callback. The mutual callbacks forced two-phase construction — a defaulted no-op writer later replaced via `bindPersistence` — every additional field of a namespace would have carried its own controller and whole-document read, and each domain re-declared a hand-written guard the registered wire schema already expresses. The namespace scope publishes a snapshot the service subscribes to and accepts writes directly, so the callback pair and the second construction phase do not exist. **Move every `localStorage` entry into settings.** Current session, drafts, panel disclosure, trajectory display state, and similar entries are browser-instance state rather than user configuration. Promoting them would synchronize transient navigation state across tabs and ports without a product contract. @@ -38,4 +40,4 @@ Appearance, Language, and busy-Enter choices follow the DSH user home across rel Boot may briefly show the domain default before the background read settles. A transient read failure keeps that default or the last good in-process value; reconnect retries. A write rejection can visibly restore the durable preference after the immediate local change. -Focused unit coverage pins schema registration, listener-before-read ordering, nonblocking activation, revisioned ordered writes, stale-response containment, failure recovery, disposal quiescence, and remote memory mode. The keyless Web settings scenario writes all three preferences through the UI, verifies the YAML document and empty legacy storage, reloads, and boots another Host on a distinct port against the same DSH home. +Focused unit coverage pins schema registration, listener-before-read ordering, nonblocking activation, schema-validated section acceptance, revisioned ordered writes, stale-response containment, failure recovery, disposal quiescence, and remote memory mode. The namespace-granular scope also carries multi-field sections, so later configuration surfaces can ride the same lifecycle instead of hand-rolling describe/mutate synchronization. The keyless Web settings scenario writes all three preferences through the UI, verifies the YAML document and empty legacy storage, reloads, and boots another Host on a distinct port against the same DSH home. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md index 376e670f9a..593646fe08 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md @@ -14,9 +14,9 @@ Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `lo 各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会显式暴露这三个 namespace,与其他 Web settings 并列;仅注册它们,绝不会跨越该配置边界。 -客户端运行时为标量偏好提供一份 `bindSettingsPreference` 生命周期。它在开始后台初始读取之前安装 `settings/changed` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档。领域服务会立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后接纳已校验的 Host 值,但不将其写回。 +客户端运行时为每个 namespace 提供一份 `bindSettingsScope` 生命周期——即 Host 侧 settings owner seam 的浏览器镜像。它在开始后台初始读取之前安装 `settings/changed` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档;它还会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式)。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。 -用户变更会同步更新实时服务,并将一项 `settings.mutate` 路径操作排入队列。控制器会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,控制器会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。 +用户变更会同步更新实时服务,并经 `scope.set` 将一项 `settings.mutate` 路径操作排入队列。scope 会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,scope 会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。 远程浏览器无法调用仅限回环请求的配置 API,因此其偏好仅保留在进程内。动态第三方主题 id 仍是内置 Host schema 之外的进程内扩展;移除其中一个会重置实时注册表,但不会替换上一个持久化的内置偏好。 @@ -28,7 +28,9 @@ Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `lo **等待初始读取,以避免暂定渲染。** 绘制页面不以配置可用为前置条件。后台读取可能引发一次实时收敛,但它会隔离失败,并保留既有的浏览器/系统/默认回落路径。 -**让每个领域拥有自己的 settings 控制器。** 并发、revision、失败、失效与释放规则完全一致;此前的主题实现已因复制这些规则产生生命周期漂移。由领域持有 schema 和解码器,可以避免把产品政策放入共享运行时。 +**让每个领域拥有自己的 settings 控制器。** 并发、revision、失败、失效与释放规则完全一致;此前的主题实现已因复制这些规则产生生命周期漂移。由领域持有 schema,可以避免把产品政策放入共享运行时。 + +**带成对 sync/persist 回调的逐字段偏好控制器。** 第一版共享生命周期经领域提供的 `sync` 回调同步单个标量字段,服务则经注入的 `persist` 回调写回。这对相互依赖的回调迫使构造分两阶段完成——写入器先默认为无操作,稍后经 `bindPersistence` 替换——namespace 每新增一个字段,本都得再携带一个自己的控制器和一次全文档读取,且每个领域都重新声明了一个已注册 wire schema 本已表达的手写校验器。namespace scope 发布一份供服务订阅的快照并直接接受写入,因此这对回调与第二个构造阶段都不存在。 **把每个 `localStorage` 条目都移入 settings。** 当前会话、草稿、面板展开状态、trajectory 显示状态和类似条目属于浏览器实例状态,而非用户配置。将它们提升为设置,会在没有产品契约的情况下,跨标签页和端口同步短暂导航状态。 @@ -38,4 +40,4 @@ Appearance、Language 和繁忙态 Enter 选择会跟随 DSH 用户 home,跨 启动时可能会在后台读取结算前短暂显示领域默认值。短暂的读取失败会保留该默认值或上一个正确的进程内值;重连时会重试。写入被拒时,界面可能会在本地值立即变化后明显恢复为持久化偏好。 -聚焦的单元测试覆盖 schema 注册、先监听后读取的顺序、非阻塞激活、携带 revision 的有序写入、陈旧响应隔离、故障恢复、释放时完全停稳,以及远程端仅内存模式。无密钥 Web settings 场景通过 UI 写入全部三项偏好,校验 YAML 文档并确认旧 `localStorage` 为空,重新加载,再使用同一个 DSH home 在不同端口上启动另一个 Host。 +聚焦的单元测试覆盖 schema 注册、先监听后读取的顺序、非阻塞激活、经 schema 校验的分节接受、携带 revision 的有序写入、陈旧响应隔离、故障恢复、释放时完全停稳,以及远程端仅内存模式。以 namespace 为粒度的 scope 也承载多字段分节,因此后续的配置表面可以沿用同一份生命周期,而不必手搭 describe/mutate 同步。无密钥 Web settings 场景通过 UI 写入全部三项偏好,校验 YAML 文档并确认旧 `localStorage` 为空,重新加载,再使用同一个 DSH home 在不同端口上启动另一个 Host。 diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 228b3a3375..72fc88c934 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -13,9 +13,11 @@ import type { Context } from 'cordis' import { type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS, } from '@deepseek-ai/dsh-client-ui-slots' -import { bindSettingsPreference, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { - isLocaleId, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, + bindSettingsScope, type ClientContext, type SettingsScope, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings, } from '../locale-settings.ts' import { en, zh, type CommonKey } from '../locales/index.ts' import { @@ -29,7 +31,7 @@ export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageR export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts' export type { CommonKey } from '../locales/index.ts' -export type { LocaleId } from '../locale-settings.ts' +export type { LocaleId, LocaleSettings } from '../locale-settings.ts' // The translate currency lives in ui-slots (the render machinery synthesizes // the seat); re-exported here so dictionary owners import one package. @@ -114,24 +116,25 @@ export class LocaleService { private snapshot: LocaleSnapshot private listeners = new Set<() => void>() private readonly ctx: Context - private persist: (id: LocaleId) => void + private readonly host: SettingsScope | undefined + /** Browser-derived locale standing wherever no explicit Host selection does. */ + private readonly provisional: LocaleId /** - * @param ctx - owning context (change events are emitted on it). - * @param persist - durable write callback for explicit locale selections. + * @param ctx - owning context (change events are emitted on it; the scope + * listener is released through ctx.effect on dispose). + * @param host - durable preference scope owned by the providing plugin; + * absent compositions (standalone dictionary registries) stay process-local. */ - constructor(ctx: Context, persist: (id: LocaleId) => void = () => {}) { + constructor(ctx: Context, host?: SettingsScope) { this.ctx = ctx - this.persist = persist - this.snapshot = Object.freeze({ active: resolveInitialLocale(), locales: LOCALES, revision: 0 }) - } - - /** - * Bind the owning plugin's durable writer before the service is provided. - * @param persist - callback accepting explicit locale changes. - */ - bindPersistence(persist: (id: LocaleId) => void): void { - this.persist = persist + this.host = host + this.provisional = resolveInitialLocale() + this.snapshot = Object.freeze({ active: this.provisional, locales: LOCALES, revision: 0 }) + if (host !== undefined) { + ctx.effect(() => host.subscribe(() => { this.adopt(host) }), 'locale: settings scope adoption') + this.adopt(host) + } } /** @@ -172,16 +175,20 @@ export class LocaleService { if (match === undefined) throw new Error(`locale "${id}" is not registered`) if (this.snapshot.active === match.id) return this.publish(match.id, true) - this.persist(match.id) + void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id) } /** - * Apply an explicit Host preference without writing it back. - * @param id - validated shipped locale. + * Adopt the scope's accepted durable selection without writing it back; an + * absent selection returns to the browser-derived locale. + * @param host - the constructor-narrowed scope driving this adoption. */ - syncPreference(id: LocaleId): void { - if (this.snapshot.active === id) return - this.publish(id, true) + private adopt(host: SettingsScope): void { + const section = host.getSnapshot().value + if (section === undefined) return + const target = section.preference ?? this.provisional + if (this.snapshot.active === target) return + this.publish(target, true) } /** @@ -345,17 +352,10 @@ export const inject = ['slots', 'connection'] * @param ctx - client cordis context. */ export function apply(ctx: ClientContext): void { - const locale = new LocaleService(ctx) - const browserLocale = locale.getLocale().active + const host = bindSettingsScope(ctx, { namespace: LOCALE_SETTINGS_NAMESPACE }) + const locale = new LocaleService(ctx, host) locale.register(COMMON_NS, { zh, en }) locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn }) - const controller = bindSettingsPreference(ctx, { - namespace: LOCALE_SETTINGS_NAMESPACE, - field: LOCALE_PREFERENCE_FIELD, - decode: value => isLocaleId(value) ? value : browserLocale, - sync: (id) => { locale.syncPreference(id) }, - }) - locale.bindPersistence((id) => { void controller.persist(id) }) ctx.provide('locale', locale) // The service IS the LocaleFace (bind + getSnapshot/subscribe): install it // so the render machinery can synthesize the `t` standard seat. diff --git a/packages/client/locale/src/index.ts b/packages/client/locale/src/index.ts index 09afbef04e..3001890569 100644 --- a/packages/client/locale/src/index.ts +++ b/packages/client/locale/src/index.ts @@ -4,18 +4,16 @@ import type { Context } from 'cordis' import z from 'schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { - LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, + LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleSettings, } from './locale-settings.ts' export { - LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, + LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, + type LocaleId, type LocaleSettings, } from './locale-settings.ts' -interface LocaleSettings { - preference?: LocaleId -} - -const LocaleSettingsSchema: z = z.object({ +/** Durable locale schema; also the wire envelope the browser scope validates against. */ +export const LocaleSettingsSchema: z = z.object({ [LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false), }) diff --git a/packages/client/locale/src/locale-settings.ts b/packages/client/locale/src/locale-settings.ts index dd1ad39339..90459981fa 100644 --- a/packages/client/locale/src/locale-settings.ts +++ b/packages/client/locale/src/locale-settings.ts @@ -12,11 +12,8 @@ export const LOCALE_IDS = ['zh', 'en'] as const /** Shipped locale identifier. */ export type LocaleId = typeof LOCALE_IDS[number] -/** - * Narrow one settings-wire value to a shipped locale. - * @param value - value crossing the settings boundary. - * @returns whether the value names a shipped locale. - */ -export function isLocaleId(value: unknown): value is LocaleId { - return LOCALE_IDS.some(locale => locale === value) +/** Durable locale section shared by the Host schema and the browser scope. */ +export interface LocaleSettings { + /** Explicit locale selection; absence delegates to the browser. */ + preference?: LocaleId } diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index 3f1f6b2c0a..152d0e6987 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -9,6 +9,7 @@ import { } from '@deepseek-ai/dsh-client-locale/client' import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { LOCALE_SETTINGS_NAMESPACE } from '../src/locale-settings.ts' +import { LocaleSettingsSchema } from '../src/index.ts' import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { createLanguageRowStore } from '../src/client/settings-store.ts' @@ -21,7 +22,7 @@ async function bench() { let revision = 0 const namespace = () => ({ ns: LOCALE_SETTINGS_NAMESPACE, - schema: {}, + schema: LocaleSettingsSchema.toJSON(), value: preference === undefined ? {} : { preference }, applies: 'live' as const, secrets: [], diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts index 9215bd51e6..0cbbd3e717 100644 --- a/packages/client/locale/tests/locale.spec.ts +++ b/packages/client/locale/tests/locale.spec.ts @@ -1,14 +1,19 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' +import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' -const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => { +const make = (host?: StubSettingsScope): { + ctx: Context + svc: LocaleService + events: LocaleSnapshot[] +} => { const ctx = new Context() const events: LocaleSnapshot[] = [] ctx.on('locale/change', (snapshot) => { events.push(snapshot) }) - return { ctx, svc: new LocaleService(ctx), events } + return { ctx, svc: new LocaleService(ctx, host?.scope), events } } /** @@ -131,19 +136,25 @@ describe('LocaleService', () => { expect(svc.getSnapshot().revision).toBe(before + 1) }) - it('setLocale requests persistence, republishes an immutable snapshot, and no-ops on same value', () => { - const { svc, events } = make() - const persist = vi.fn() - svc.bindPersistence(persist) + it('setLocale writes through the scope, republishes an immutable snapshot, and no-ops on same value', () => { + const host = stubSettingsScope() + const { svc, events } = make(host) svc.setLocale('en') expect(svc.getLocale().active).toBe('en') - expect(persist).toHaveBeenCalledWith('en') + expect(host.set).toHaveBeenCalledWith('preference', 'en') expect(events).toHaveLength(1) expect(events[0]).toBe(svc.getLocale()) expect(events[0]!.revision).toBe(1) svc.setLocale('en') expect(events).toHaveLength(1) - expect(persist).toHaveBeenCalledOnce() + expect(host.set).toHaveBeenCalledOnce() + }) + + it('setLocale without a host scope stays process-local', () => { + const { svc, events } = make() + svc.setLocale('en') + expect(svc.getLocale().active).toBe('en') + expect(events).toHaveLength(1) }) it('throws on unknown locale ids', () => { @@ -151,18 +162,36 @@ describe('LocaleService', () => { expect(() => { svc.setLocale('fr') }).toThrow('not registered') }) - it('syncs a Host preference over the browser language without writing it back', () => { - const { svc, events } = make() - const persist = vi.fn() - svc.bindPersistence(persist) - svc.syncPreference('en') + it('adopts a Host preference over the browser language without writing it back', () => { + const host = stubSettingsScope() + const { svc, events } = make(host) + host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true }) expect(svc.getLocale().active).toBe('en') expect(events).toHaveLength(1) - expect(persist).not.toHaveBeenCalled() - svc.syncPreference('en') + expect(host.set).not.toHaveBeenCalled() + host.publish({ value: { preference: 'en' }, revision: 2 }) expect(events).toHaveLength(1) }) + it('an absent Host preference returns to the browser-derived locale', () => { + const host = stubSettingsScope() + const { svc } = make(host) + host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true }) + expect(svc.getLocale().active).toBe('en') + host.publish({ value: {}, revision: 2 }) + expect(svc.getLocale().active).toBe('zh') + }) + + it('adopts a section already standing at construction and releases its subscription on dispose', async () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true }) + const { ctx, svc } = make(host) + expect(svc.getLocale().active).toBe('en') + expect(host.listenerCount()).toBe(1) + await ctx.fiber.dispose() + expect(host.listenerCount()).toBe(0) + }) + it('opens provisionally in the browser language, matching regional variants on their primary subtag', () => { stubLanguages('en-GB', 'zh-CN') expect(make().svc.getLocale().active).toBe('en') diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 5698297cb4..3f5bd9263b 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: c05089badb29ad0e22ed1f66d7804eccbb11c1d4 -README.zh.md: ccbb96266cf8ca442adbdbf9784c54400593d5c2 +README.md: 767352a0682f16abcbfce3c226cda790adcc8011 +README.zh.md: 791a74691cd20705614ac782d6b55d9af290955c diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index c05089badb..767352a068 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -4,7 +4,7 @@ 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`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. 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. -`bindSettingsPreference` is the browser lifecycle for one domain-owned scalar setting. It subscribes before starting a nonblocking initial read, serializes writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. Loopback pages use the Host settings API; remote pages stay in memory. Domain packages own the namespace schema, value guard, default, and live service rather than putting product policy in runtime. +`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 diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index ccbb96266c..791a74691c 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -4,7 +4,7 @@ 客户端 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`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 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` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 -`bindSettingsPreference` 是单项由领域持有的标量设置所用的浏览器生命周期。它在开始非阻塞初始读取前建立订阅,使用已知最新 namespace revision 串行写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。回环页面使用 Host settings API,远程页面则只保留内存状态。namespace schema、取值校验器、默认值与实时服务归领域包所有,而非把产品政策放入运行时。 +`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 声明注入 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index b636316b68..b64a4de0ec 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -32,6 +32,7 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", @@ -53,7 +54,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "schemastery": "^3.18.0" }, "files": [ "lib/index.js", diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 2854a16659..04748b445e 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -21,8 +21,8 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' -export { bindSettingsPreference, SettingsPreferenceController } from './settings-preference.ts' -export type { SettingsPreferenceSpec } from './settings-preference.ts' +export { bindSettingsScope, SettingsScopeController } from './settings-scope.ts' +export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-scope.ts' export type { Session } from './sessions/session.ts' export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' export type { diff --git a/packages/client/runtime/src/client/settings-preference.ts b/packages/client/runtime/src/client/settings-preference.ts deleted file mode 100644 index a459999cc7..0000000000 --- a/packages/client/runtime/src/client/settings-preference.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** Host-backed scalar preference synchronization for browser plugins. */ - -import type { Context } from 'cordis' -import type { - ConnectionHandle, IApiClient, SettingsNamespaceView, -} from '@deepseek-ai/dsh-client-connection/client' - -/** Domain-owned description of one scalar field in a settings namespace. */ -export interface SettingsPreferenceSpec { - /** Settings namespace registered by the owning Host plugin. */ - namespace: string - /** Scalar field inside that namespace. */ - field: string - /** Validate a wire value; undefined leaves the current in-process value active. */ - decode(value: unknown): T | undefined - /** Apply a validated Host value without writing it back. */ - sync(value: T): void -} - -type SettingsFace = Pick - -/** - * Serializes one scalar preference's Host reads and writes. Reads never block - * plugin activation; writes carry the latest known namespace revision and - * teardown waits for the operation already crossing the wire. - */ -export class SettingsPreferenceController { - private tail: Promise = Promise.resolve() - private readGeneration = 0 - private writeGeneration = 0 - private revision: number | undefined - private disposed = false - - /** - * @param api - settings wire face. - * @param spec - namespace, field validator, and live target. - * @param persistence - remote browsers remain process-local because settings RPCs are loopback-only. - */ - constructor( - private readonly api: SettingsFace, - private readonly spec: SettingsPreferenceSpec, - private readonly persistence: 'host' | 'memory' = 'host', - ) {} - - /** - * Queue a Host refresh; a newer read or user write suppresses stale publication. - * @returns settlement after the queued read completes or is skipped. - */ - load(): Promise { - const generation = ++this.readGeneration - return this.enqueue(() => this.read(generation)) - } - - /** - * Queue one user preference write. Rapid selections preserve mutation order, - * while only the latest settlement may resynchronize the live target. - * @param value - validated domain preference selected by the user. - * @returns settlement after the write and any latest-write recovery read. - */ - persist(value: T): Promise { - this.readGeneration += 1 - const generation = ++this.writeGeneration - return this.enqueue(async () => { - let response: Awaited> - try { - response = await this.api.settings.mutate({ - ns: this.spec.namespace, - ops: [{ op: 'set', path: [this.spec.field], value }], - ...(this.revision === undefined ? {} : { expectedRevision: this.revision }), - }) - } catch (_settingsWriteFailure) { - if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) - return - } - if (!response.result.ok) { - if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) - return - } - this.accept(response.result.value, generation === this.writeGeneration) - }) - } - - /** - * Stop queued operations and wait for the current wire call to settle. - * @returns settlement after the controller reaches quiescence. - */ - async dispose(): Promise { - this.disposed = true - this.readGeneration += 1 - this.writeGeneration += 1 - await this.tail - } - - private enqueue(operation: () => Promise): Promise { - if (this.persistence === 'memory' || this.disposed) return Promise.resolve() - const task = this.tail.then(async () => { - if (this.disposed) return - await operation() - }) - // The returned task carries its own settlement to the caller; the queue - // tail is kept fulfilled so one failed target callback cannot strand later operations. - this.tail = task.catch(() => {}) - return task - } - - private async read(generation: number): Promise { - let response: Awaited> - try { - response = await this.api.settings.describe({}) - } catch (_settingsReadFailure) { - return - } - if (!response.result.ok || this.disposed) return - const view = response.result.value.namespaces.find(candidate => candidate.ns === this.spec.namespace) - if (view === undefined) return - this.accept(view, generation === this.readGeneration) - } - - private accept(view: SettingsNamespaceView, publish: boolean): void { - this.revision = view.revision - if (!publish || typeof view.value !== 'object' || view.value === null) return - const value = this.spec.decode((view.value as Record)[this.spec.field]) - if (value !== undefined) this.spec.sync(value) - } -} - -/** - * Bind one controller to settings and connection invalidations on the caller's - * plugin lifecycle. Listeners exist before the initial background read starts. - * @param ctx - owning browser plugin context. - * @param spec - domain-owned scalar preference contract. - * @returns the bound controller used by the domain's user-write callback. - */ -export function bindSettingsPreference( - ctx: Context, - spec: SettingsPreferenceSpec, -): SettingsPreferenceController { - const connection = ctx.get('connection') as ConnectionHandle - const controller = new SettingsPreferenceController( - connection.api, - spec, - connection.isLoopback ? 'host' : 'memory', - ) - ctx.effect(() => { - const refresh = (namespace?: string): void => { - if (namespace !== undefined && namespace !== spec.namespace) return - void controller.load() - } - const disposers = [ - ctx.on('settings/changed', refresh), - ctx.on('connection/reset', () => { refresh() }), - ] - void controller.load() - return async () => { - for (const dispose of disposers) dispose() - await controller.dispose() - } - }, `runtime: ${spec.namespace}.${spec.field} preference`) - return controller -} diff --git a/packages/client/runtime/src/client/settings-scope.ts b/packages/client/runtime/src/client/settings-scope.ts new file mode 100644 index 0000000000..91b6c7ec3a --- /dev/null +++ b/packages/client/runtime/src/client/settings-scope.ts @@ -0,0 +1,261 @@ +/** Host-backed settings-namespace synchronization for browser plugins. */ + +import type { Context } from 'cordis' +import type { + ConnectionHandle, IApiClient, SettingsNamespaceView, +} from '@deepseek-ai/dsh-client-connection/client' +import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form' +import { createSnapshotStore, type SnapshotStore } from './contract/store.ts' + +/** Client-side sync state of one settings namespace. */ +export interface SettingsScopeSnapshot { + /** + * `loading` until the first accepted section, `ready` while one stands, and + * `unavailable` when the namespace is not exposed to this client or the + * connection keeps preferences process-local (memory mode). + */ + status: 'loading' | 'ready' | 'unavailable' + /** Last accepted schema-resolved section; undefined before the first acceptance. */ + value: T | undefined + /** Namespace revision fencing the next write; undefined before the first Host view. */ + revision: number | undefined + /** Whether the Host document accepts writes; memory mode never does. */ + writable: boolean + /** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */ + mode: 'host' | 'memory' +} + +/** Domain-owned description of one settings namespace consumed by a browser plugin. */ +export interface SettingsScopeSpec { + /** Settings namespace registered by the owning Host plugin. */ + namespace: string + /** + * Narrow one wire section; undefined keeps the last accepted value. The + * default validates the section against the namespace's own serialized wire + * schema, so domains add a decoder only to narrow beyond that schema. + */ + decode?: (section: unknown) => T | undefined +} + +/** + * Reactive owner handle over one namespace's durable section — the browser + * mirror of the Host-side `SettingsScope` owner seam. Domain services read + * and observe the snapshot and route explicit user choices through `set`. + */ +export interface SettingsScope { + /** @returns the current sync snapshot (stable reference until the next change). */ + getSnapshot(): SettingsScopeSnapshot + /** + * Observe snapshot replacements. + * @param listener - invoked after each snapshot change. + * @returns the disposer removing this listener. + */ + subscribe(listener: () => void): () => void + /** + * Queue one field write. Rapid writes preserve mutation order, each carries + * the latest known namespace revision, and only the latest settlement may + * publish; a rejected or failed latest write reloads Host state instead. + * @param field - scalar field inside the namespace section. + * @param value - JSON-shaped value selected by the user. + * @returns settlement after the write and any latest-write recovery read. + */ + set(field: string, value: unknown): Promise +} + +type SettingsFace = Pick + +/** + * Serializes one namespace's Host reads and writes behind a snapshot store. + * Reads never block plugin activation; writes carry the latest known + * namespace revision and teardown waits for the operation already crossing + * the wire. + */ +export class SettingsScopeController implements SettingsScope { + private readonly store: SnapshotStore> + private tail: Promise = Promise.resolve() + private readGeneration = 0 + private writeGeneration = 0 + private disposed = false + + /** + * @param api - settings wire face. + * @param spec - namespace identity and optional narrowing decoder. + * @param persistence - remote browsers remain process-local because settings RPCs are loopback-only. + */ + constructor( + private readonly api: SettingsFace, + private readonly spec: SettingsScopeSpec, + private readonly persistence: 'host' | 'memory' = 'host', + ) { + this.store = createSnapshotStore>({ + status: persistence === 'host' ? 'loading' : 'unavailable', + value: undefined, + revision: undefined, + writable: false, + mode: persistence, + }) + } + + /** @returns the current sync snapshot (stable reference until the next change). */ + getSnapshot(): SettingsScopeSnapshot { + return this.store.getSnapshot() + } + + /** + * Observe snapshot replacements. + * @param listener - invoked after each snapshot change. + * @returns the disposer removing this listener. + */ + subscribe(listener: () => void): () => void { + return this.store.subscribe(listener) + } + + /** + * Queue a Host refresh; a newer read or user write suppresses stale publication. + * @returns settlement after the queued read completes or is skipped. + */ + load(): Promise { + const generation = ++this.readGeneration + return this.enqueue(() => this.read(generation)) + } + + /** + * Queue one field write; see {@link SettingsScope.set} for the ordering, + * revision, and recovery contract. + * @param field - scalar field inside the namespace section. + * @param value - JSON-shaped value selected by the user. + * @returns settlement after the write and any latest-write recovery read. + */ + set(field: string, value: unknown): Promise { + this.readGeneration += 1 + const generation = ++this.writeGeneration + return this.enqueue(async () => { + const revision = this.getSnapshot().revision + let response: Awaited> + try { + response = await this.api.settings.mutate({ + ns: this.spec.namespace, + ops: [{ op: 'set', path: [field], value }], + ...(revision === undefined ? {} : { expectedRevision: revision }), + }) + } catch (_settingsWriteFailure) { + if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + return + } + if (!response.result.ok) { + if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + return + } + this.accept(response.result.value, generation === this.writeGeneration) + }) + } + + /** + * Stop queued operations and wait for the current wire call to settle. + * @returns settlement after the controller reaches quiescence. + */ + async dispose(): Promise { + this.disposed = true + this.readGeneration += 1 + this.writeGeneration += 1 + await this.tail + } + + private enqueue(operation: () => Promise): Promise { + if (this.persistence === 'memory' || this.disposed) return Promise.resolve() + const task = this.tail.then(async () => { + if (this.disposed) return + await operation() + }) + // The returned task carries its own settlement to the caller; the queue + // tail is kept fulfilled so one failed subscriber cannot strand later operations. + this.tail = task.catch(() => {}) + return task + } + + private async read(generation: number): Promise { + let response: Awaited> + try { + response = await this.api.settings.describe({}) + } catch (_settingsReadFailure) { + return + } + if (!response.result.ok || this.disposed) return + const { namespaces, writable } = response.result.value + const view = namespaces.find(candidate => candidate.ns === this.spec.namespace) + const publish = generation === this.readGeneration + if (view === undefined) { + if (publish) { + this.store.update((draft) => { + draft.status = 'unavailable' + draft.writable = writable + }) + } + return + } + this.accept(view, publish, writable) + } + + private accept(view: SettingsNamespaceView, publish: boolean, writable?: boolean): void { + const decoded = publish ? this.decode(view) : undefined + this.store.update((draft) => { + draft.revision = view.revision + if (writable !== undefined) draft.writable = writable + if (decoded === undefined) return + draft.status = 'ready' + draft.value = decoded + }) + } + + private decode(view: SettingsNamespaceView): T | undefined { + if (this.spec.decode !== undefined) return this.spec.decode(view.value) + // Sections are plain objects by construction; schemastery alone would + // resolve null or an array through object defaults instead of refusing. + if (typeof view.value !== 'object' || view.value === null || Array.isArray(view.value)) return undefined + let failure: string | undefined + try { + failure = validateDraft(rehydrateSchema(view.schema), view.value) + } catch (_malformedSchemaEnvelope) { + // A schema envelope this client cannot rehydrate vouches for no section; + // the value is treated exactly like a schema-invalid one. + return undefined + } + return failure === undefined ? view.value as T : undefined + } +} + +/** + * Bind one namespace scope to settings and connection invalidations on the + * caller's plugin lifecycle. Listeners exist before the initial background + * read starts, so activation never blocks on the settings transport. + * @param ctx - owning browser plugin context. + * @param spec - domain-owned namespace contract. + * @returns the bound scope consumed by the domain's services and rows. + */ +export function bindSettingsScope( + ctx: Context, + spec: SettingsScopeSpec, +): SettingsScope { + const connection = ctx.get('connection') as ConnectionHandle + const controller = new SettingsScopeController( + connection.api, + spec, + connection.isLoopback ? 'host' : 'memory', + ) + ctx.effect(() => { + const refresh = (namespace?: string): void => { + if (namespace !== undefined && namespace !== spec.namespace) return + void controller.load() + } + const disposers = [ + ctx.on('settings/changed', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + void controller.load() + return async () => { + for (const dispose of disposers) dispose() + await controller.dispose() + } + }, `runtime: ${spec.namespace} settings scope`) + return controller +} diff --git a/packages/client/runtime/tests/settings-preference.spec.ts b/packages/client/runtime/tests/settings-preference.spec.ts deleted file mode 100644 index a93df780bb..0000000000 --- a/packages/client/runtime/tests/settings-preference.spec.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' -import { - bindSettingsPreference, SettingsPreferenceController, -} from '../src/client/settings-preference.ts' - -type Preference = 'light' | 'dark' | 'system' - -let rpc = 0 - -function ok(value: T): RpcResponse { - return { rpcId: `preference-${rpc++}` as never, result: { ok: true, value } } -} - -function rejected(): RpcResponse { - return { - rpcId: `preference-${rpc++}` as never, - result: { - ok: false, - error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } }, - }, - } -} - -function view(value: unknown, revision = 0): SettingsNamespaceView { - return { - ns: 'ui-test', - schema: {}, - value, - applies: 'live', - secrets: [], - revision, - } -} - -function described(value: unknown, revision = 0) { - return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] }) -} - -function deferred() { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((res, rej) => { resolve = res; reject = rej }) - return { promise, resolve, reject } -} - -function spec(values: Preference[]) { - return { - namespace: 'ui-test', - field: 'preference', - decode: (value: unknown): Preference | undefined => - value === 'light' || value === 'dark' || value === 'system' ? value : undefined, - sync: (value: Preference) => { values.push(value) }, - } -} - -describe('SettingsPreferenceController', () => { - it('loads only a valid owned field and contains unavailable transports', async () => { - const values: Preference[] = [] - const describe = vi.fn() - .mockResolvedValueOnce(described({ preference: 'dark' }, 3)) - .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) - .mockResolvedValueOnce(described({ preference: 'sepia' })) - .mockResolvedValueOnce(described(null)) - .mockResolvedValueOnce(rejected()) - .mockRejectedValueOnce(new Error('offline')) - const controller = new SettingsPreferenceController({ settings: { describe } } as never, spec(values)) - for (let i = 0; i < 6; i++) await controller.load() - expect(values).toEqual(['dark']) - }) - - it('serializes rapid writes, carries revisions, and publishes only the latest settlement', async () => { - const first = deferred>() - const values: Preference[] = [] - const describe = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4)) - const mutate = vi.fn() - .mockReturnValueOnce(first.promise) - .mockResolvedValueOnce(ok(view({ preference: 'light' }, 6))) - const controller = new SettingsPreferenceController( - { settings: { describe, mutate } } as never, - spec(values), - ) - await controller.load() - const dark = controller.persist('dark') - const light = controller.persist('light') - await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) - first.resolve(ok(view({ preference: 'dark' }, 5))) - await Promise.all([dark, light]) - expect(values).toEqual(['system', 'light']) - expect(mutate).toHaveBeenNthCalledWith(1, { - ns: 'ui-test', - ops: [{ op: 'set', path: ['preference'], value: 'dark' }], - expectedRevision: 4, - }) - expect(mutate).toHaveBeenNthCalledWith(2, { - ns: 'ui-test', - ops: [{ op: 'set', path: ['preference'], value: 'light' }], - expectedRevision: 5, - }) - }) - - it('recovers the latest rejected or thrown write from Host state', async () => { - const values: Preference[] = [] - const describe = vi.fn() - .mockResolvedValueOnce(described({ preference: 'system' }, 2)) - .mockResolvedValueOnce(described({ preference: 'light' }, 3)) - const mutate = vi.fn() - .mockResolvedValueOnce(rejected()) - .mockRejectedValueOnce(new Error('offline')) - const controller = new SettingsPreferenceController( - { settings: { describe, mutate } } as never, - spec(values), - ) - await controller.persist('dark') - await controller.persist('system') - expect(values).toEqual(['system', 'light']) - }) - - it('does not recover superseded rejected or thrown writes', async () => { - const values: Preference[] = [] - const describe = vi.fn() - const mutate = vi.fn() - .mockResolvedValueOnce(rejected()) - .mockRejectedValueOnce(new Error('offline')) - .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) - const controller = new SettingsPreferenceController( - { settings: { describe, mutate } } as never, - spec(values), - ) - await Promise.all([ - controller.persist('dark'), - controller.persist('system'), - controller.persist('light'), - ]) - expect(describe).not.toHaveBeenCalled() - expect(values).toEqual(['light']) - }) - - it('keeps the queue usable when a target callback throws', async () => { - const describe = vi.fn() - .mockResolvedValueOnce(described({ preference: 'dark' })) - .mockResolvedValueOnce(described({ preference: 'sepia' })) - const controller = new SettingsPreferenceController( - { settings: { describe } } as never, - { ...spec([]), sync: () => { throw new Error('target failed') } }, - ) - await expect(controller.load()).rejects.toThrow('target failed') - await expect(controller.load()).resolves.toBeUndefined() - }) - - it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => { - const first = deferred>() - const mutate = vi.fn().mockReturnValue(first.promise) - const values: Preference[] = [] - const controller = new SettingsPreferenceController( - { settings: { mutate } } as never, - spec(values), - ) - const dark = controller.persist('dark') - await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) - const light = controller.persist('light') - let stopped = false - const stop = controller.dispose().then(() => { stopped = true }) - await Promise.resolve() - expect(stopped).toBe(false) - first.resolve(ok(view({ preference: 'dark' }, 1))) - await Promise.all([dark, light, stop]) - await controller.persist('system') - await controller.load() - expect(mutate).toHaveBeenCalledOnce() - expect(values).toEqual([]) - }) - - it('keeps remote-browser preferences in memory without Host calls', async () => { - const describe = vi.fn() - const mutate = vi.fn() - const controller = new SettingsPreferenceController( - { settings: { describe, mutate } } as never, - spec([]), - 'memory', - ) - await controller.load() - await controller.persist('dark') - await controller.dispose() - expect(describe).not.toHaveBeenCalled() - expect(mutate).not.toHaveBeenCalled() - }) -}) - -describe('bindSettingsPreference', () => { - it('subscribes before the initial read and converges to the latest queued invalidation', async () => { - const initial = deferred>() - const describe = vi.fn() - .mockReturnValueOnce(initial.promise) - .mockResolvedValueOnce(described({ preference: 'light' }, 2)) - .mockResolvedValueOnce(described({ preference: 'system' }, 3)) - const ctx = new Context() - ctx.provide('connection', { - api: { settings: { describe } }, - isLoopback: true, - } as never) - const values: Preference[] = [] - const fiber = ctx.plugin({ - inject: ['connection'], - apply: (scope: Context) => { bindSettingsPreference(scope, spec(values)) }, - }) - await fiber.await() - await vi.waitFor(() => { expect(describe).toHaveBeenCalledOnce() }) - ctx.emit('settings/changed', 'unrelated') - ctx.emit('settings/changed', 'ui-test') - ctx.emit('connection/reset') - initial.resolve(described({ preference: 'dark' }, 1)) - await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(3) }) - await vi.waitFor(() => { expect(values).toEqual(['system']) }) - await fiber.dispose() - ctx.emit('settings/changed', 'ui-test') - await Promise.resolve() - expect(describe).toHaveBeenCalledTimes(3) - }) - - it('binds a remote browser in memory without starting a settings read', async () => { - const describe = vi.fn() - const ctx = new Context() - ctx.provide('connection', { - api: { settings: { describe } }, - isLoopback: false, - } as never) - const fiber = ctx.plugin({ - inject: ['connection'], - apply: (scope: Context) => { bindSettingsPreference(scope, spec([])) }, - }) - await fiber.await() - await fiber.dispose() - expect(describe).not.toHaveBeenCalled() - }) -}) diff --git a/packages/client/runtime/tests/settings-scope.spec.ts b/packages/client/runtime/tests/settings-scope.spec.ts new file mode 100644 index 0000000000..db980bf6d1 --- /dev/null +++ b/packages/client/runtime/tests/settings-scope.spec.ts @@ -0,0 +1,352 @@ +import { Context } from 'cordis' +import z from 'schemastery' +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { + bindSettingsScope, SettingsScopeController, type SettingsScope, +} from '../src/client/settings-scope.ts' + +interface UiTestSettings { + preference: 'light' | 'dark' | 'system' +} + +const ENVELOPE = z.object({ + preference: z.union(['light', 'dark', 'system']).default('system'), +}).toJSON() + +let rpc = 0 + +function ok(value: T): RpcResponse { + return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } } +} + +function rejected(): RpcResponse { + return { + rpcId: `scope-${rpc++}` as never, + result: { + ok: false, + error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } }, + }, + } +} + +function view(value: unknown, revision = 0): SettingsNamespaceView { + return { + ns: 'ui-test', + schema: ENVELOPE, + value, + applies: 'live', + secrets: [], + revision, + } +} + +function described(value: unknown, revision = 0) { + return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] }) +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +/** Record each distinct published section, starting from the current one. */ +function trackValues(scope: SettingsScope): Array { + const seen: Array = [scope.getSnapshot().value] + scope.subscribe(() => { + const value = scope.getSnapshot().value + if (value !== seen[seen.length - 1]) seen.push(value) + }) + return seen +} + +describe('SettingsScopeController', () => { + it('starts loading and publishes a schema-valid section with revision and writability', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + expect(scope.getSnapshot()).toEqual({ + status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host', + }) + await scope.load() + expect(scope.getSnapshot()).toEqual({ + status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host', + }) + }) + + it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' }, 3)) + .mockResolvedValueOnce(described({ preference: 'sepia' }, 4)) + .mockResolvedValueOnce(described(null, 5)) + .mockResolvedValueOnce(described('scalar', 6)) + .mockResolvedValueOnce(described(['queue'], 7)) + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + const good = trackValues(scope) + for (let i = 0; i < 7; i++) await scope.load() + expect(scope.getSnapshot()).toMatchObject({ + status: 'ready', value: { preference: 'dark' }, revision: 7, + }) + expect(good).toEqual([undefined, { preference: 'dark' }]) + }) + + it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => { + const broken = { ...view({ preference: 'dark' }, 2), schema: null } + const describeCall = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] })) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + await scope.load() + expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 }) + }) + + it('suppresses a superseded read of an unexposed namespace', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) + .mockResolvedValueOnce(described({ preference: 'dark' }, 1)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + const statuses: string[] = [] + scope.subscribe(() => { statuses.push(scope.getSnapshot().status) }) + const stale = scope.load() + const fresh = scope.load() + await Promise.all([stale, fresh]) + expect(statuses).not.toContain('unavailable') + expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } }) + }) + + it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'light' }, 1)) + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) + .mockResolvedValueOnce(described({ preference: 'system' }, 2)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + await scope.load() + expect(scope.getSnapshot().status).toBe('ready') + await scope.load() + expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } }) + await scope.load() + expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 }) + }) + + it('applies a custom decode override in place of the wire schema', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'light' }, 1)) + .mockResolvedValueOnce(described({ preference: 'dark' }, 2)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { + namespace: 'ui-test', + decode: section => (section as UiTestSettings).preference === 'dark' + ? section as UiTestSettings + : undefined, + }, + ) + await scope.load() + expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 }) + await scope.load() + expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 }) + }) + + it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => { + const first = deferred>() + const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4)) + const mutate = vi.fn() + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 6))) + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + const published = trackValues(scope) + await scope.load() + const dark = scope.set('preference', 'dark') + const light = scope.set('preference', 'light') + await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + first.resolve(ok(view({ preference: 'dark' }, 5))) + await Promise.all([dark, light]) + expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light']) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 }) + expect(mutate).toHaveBeenNthCalledWith(1, { + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'dark' }], + expectedRevision: 4, + }) + expect(mutate).toHaveBeenNthCalledWith(2, { + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'light' }], + expectedRevision: 5, + }) + }) + + it('recovers the latest rejected or thrown write from Host state', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'system' }, 2)) + .mockResolvedValueOnce(described({ preference: 'light' }, 3)) + const mutate = vi.fn() + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + const published = trackValues(scope) + await scope.set('preference', 'dark') + await scope.set('preference', 'system') + expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light']) + }) + + it('does not recover superseded rejected or thrown writes', async () => { + const describeCall = vi.fn() + const mutate = vi.fn() + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + const published = trackValues(scope) + await Promise.all([ + scope.set('preference', 'dark'), + scope.set('preference', 'system'), + scope.set('preference', 'light'), + ]) + expect(describeCall).not.toHaveBeenCalled() + expect(published.map(section => section?.preference)).toEqual([undefined, 'light']) + }) + + it('keeps the write queue usable when a subscriber throws', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' }, 1)) + .mockResolvedValueOnce(described({ preference: 'light' }, 2)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + let thrown = false + scope.subscribe(() => { + if (thrown) return + thrown = true + throw new Error('subscriber failed') + }) + await expect(scope.load()).rejects.toThrow('subscriber failed') + await expect(scope.load()).resolves.toBeUndefined() + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 }) + }) + + it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => { + const first = deferred>() + const mutate = vi.fn().mockReturnValue(first.promise) + const describeCall = vi.fn() + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + const published = trackValues(scope) + const dark = scope.set('preference', 'dark') + await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + const light = scope.set('preference', 'light') + let stopped = false + const stop = scope.dispose().then(() => { stopped = true }) + await Promise.resolve() + expect(stopped).toBe(false) + first.resolve(ok(view({ preference: 'dark' }, 1))) + await Promise.all([dark, light, stop]) + await scope.set('preference', 'system') + await scope.load() + expect(mutate).toHaveBeenCalledOnce() + expect(describeCall).not.toHaveBeenCalled() + expect(published).toEqual([undefined]) + }) + + it('keeps a remote browser in memory mode without Host calls', async () => { + const describeCall = vi.fn() + const mutate = vi.fn() + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + 'memory', + ) + expect(scope.getSnapshot()).toEqual({ + status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory', + }) + await scope.load() + await scope.set('preference', 'dark') + await scope.dispose() + expect(describeCall).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() + }) +}) + +describe('bindSettingsScope', () => { + it('subscribes before the initial read and converges to the latest queued invalidation', async () => { + const initial = deferred>() + const describeCall = vi.fn() + .mockReturnValueOnce(initial.promise) + .mockResolvedValueOnce(described({ preference: 'light' }, 2)) + .mockResolvedValueOnce(described({ preference: 'system' }, 3)) + const ctx = new Context() + ctx.provide('connection', { + api: { settings: { describe: describeCall } }, + isLoopback: true, + } as never) + let scope!: SettingsScope + const fiber = ctx.plugin({ + inject: ['connection'], + apply: (plugin: Context) => { + scope = bindSettingsScope(plugin, { namespace: 'ui-test' }) + }, + }) + await fiber.await() + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() }) + ctx.emit('settings/changed', 'unrelated') + ctx.emit('settings/changed', 'ui-test') + ctx.emit('connection/reset') + initial.resolve(described({ preference: 'dark' }, 1)) + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) + await vi.waitFor(() => { + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 }) + }) + await fiber.dispose() + ctx.emit('settings/changed', 'ui-test') + await Promise.resolve() + expect(describeCall).toHaveBeenCalledTimes(3) + }) + + it('binds a remote browser in memory mode without starting a settings read', async () => { + const describeCall = vi.fn() + const ctx = new Context() + ctx.provide('connection', { + api: { settings: { describe: describeCall } }, + isLoopback: false, + } as never) + let scope!: SettingsScope + const fiber = ctx.plugin({ + inject: ['connection'], + apply: (plugin: Context) => { + scope = bindSettingsScope(plugin, { namespace: 'ui-test' }) + }, + }) + await fiber.await() + expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false }) + await fiber.dispose() + expect(describeCall).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index f1512c7059..4357200479 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../connection" }, + { + "path": "../schema-form" + }, { "path": "../../host/apiproxy" }, diff --git a/packages/client/test-runtime/README.i18n.yaml b/packages/client/test-runtime/README.i18n.yaml index 26845c1d5c..4707337ef0 100644 --- a/packages/client/test-runtime/README.i18n.yaml +++ b/packages/client/test-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/test-runtime/README.md -README.md: 74da8fde7fd9cc3733d2d1ae03dd3d213e4d553e -README.zh.md: a86b9e469a5632886891628267002a14588afeaa +README.md: 455d6f564cea2cb8f88165a8bba1047c762d2fb0 +README.zh.md: e292c57c21dde1f7639ce37ee9b65930c6d153ea diff --git a/packages/client/test-runtime/README.md b/packages/client/test-runtime/README.md index 74da8fde7f..455d6f564c 100644 --- a/packages/client/test-runtime/README.md +++ b/packages/client/test-runtime/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) jsdom slot test runtime for client feature specs: a real Cordis `Context`, the production `SlotsService` and web-react renderer, assembled around typed session/workspace doubles. Feature suites exercise declaration, registration, scope, store, inject, rendering, updates, and disposal without hand-building the machinery per suite — and without a second implementation of any production logic. -The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face. +The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`; `stubSettingsScope` is a `SettingsScope` with test-driven publications and a write spy), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face. Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `

` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) to keep `.snap` files structural and collapses `` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis. diff --git a/packages/client/test-runtime/README.zh.md b/packages/client/test-runtime/README.zh.md index a86b9e469a..e292c57c21 100644 --- a/packages/client/test-runtime/README.zh.md +++ b/packages/client/test-runtime/README.zh.md @@ -4,7 +4,7 @@ 面向 client feature 测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotsService` 与 web-react 渲染器,围绕带类型的 session/workspace 测试替身组装。feature 套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。 -替身实现的正是 feature 经 ctx 拿到的对外面(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。 +替身实现的正是 feature 经 ctx 拿到的对外面(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`;`stubSettingsScope` 是发布由测试驱动、带写入 spy 的 `SettingsScope`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。 局部 DOM 快照:`declare(children)` 注册自动 frame,逐 key 的 `
` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图(container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`)保持 `.snap` 只含结构,并把 `` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)`;`mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。 diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index 5ef5350434..4703b0112c 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -34,6 +34,8 @@ import type { Stabilizer } from './fixtures.ts' export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts' export { FixtureSession, TestSessions } from './sessions.ts' +export { stubSettingsScope } from './settings-scope.ts' +export type { StubSettingsScope } from './settings-scope.ts' export { TestWorkspaces } from './workspaces.ts' export { conversationSnapshot, workspaceListState } from './fixtures.ts' export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts' diff --git a/packages/client/test-runtime/src/settings-scope.ts b/packages/client/test-runtime/src/settings-scope.ts new file mode 100644 index 0000000000..c901221018 --- /dev/null +++ b/packages/client/test-runtime/src/settings-scope.ts @@ -0,0 +1,48 @@ +/** Test double for the client settings-scope seam. */ +import { vi } from 'vitest' +import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client' + +/** Handle over one stubbed scope: the scope, its write spy, and publication controls. */ +export interface StubSettingsScope { + /** The scope face handed to the service under test. */ + scope: SettingsScope + /** Spy behind `scope.set`; resolves immediately. */ + set: ReturnType + /** @returns how many listeners are currently subscribed (disposal assertions). */ + listenerCount(): number + /** + * Replace part of the snapshot and notify subscribers, as a Host + * acceptance would. + * @param next - snapshot fields to replace. + */ + publish(next: Partial>): void +} + +/** + * Build an in-memory settings scope for service specs: starts in the host + * loading state, records writes, and lets the test publish Host acceptances. + * @returns the stub handle. + */ +export function stubSettingsScope(): StubSettingsScope { + let snapshot: SettingsScopeSnapshot = { + status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host', + } + const listeners = new Set<() => void>() + const set = vi.fn(() => Promise.resolve()) + return { + scope: { + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + set, + }, + set, + listenerCount: () => listeners.size, + publish: (next) => { + snapshot = { ...snapshot, ...next } + for (const listener of [...listeners]) listener() + }, + } +} diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 5fc1e9e353..eda582504f 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,7 +1,7 @@ /** Registers the conversation components, shared store, and service callbacks. */ import type { Context } from 'cordis' import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import { bindSettingsPreference, type ISessions, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSettingsScope, type ISessions, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -38,9 +38,7 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { en, NS, zh, type ConversationKey } from './locales.ts' -import { - BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, isBusyEnterBehavior, -} from '../submission-settings.ts' +import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -106,14 +104,9 @@ export function apply(ctx: Context): void { // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() - const submissionPolicy = new ComposerSubmissionPolicy() - const preference = bindSettingsPreference(ctx, { - namespace: CONVERSATION_SETTINGS_NAMESPACE, - field: BUSY_ENTER_FIELD, - decode: value => isBusyEnterBehavior(value) ? value : undefined, - sync: (behavior) => { submissionPolicy.syncPreference(behavior) }, - }) - submissionPolicy.bindPersistence((behavior) => { void preference.persist(behavior) }) + const submissionPolicy = new ComposerSubmissionPolicy( + bindSettingsScope(ctx, { namespace: CONVERSATION_SETTINGS_NAMESPACE }), + ) ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', diff --git a/packages/client/ui-conversation/src/client/input/submission-policy.ts b/packages/client/ui-conversation/src/client/input/submission-policy.ts index 968406972c..27b1cff326 100644 --- a/packages/client/ui-conversation/src/client/input/submission-policy.ts +++ b/packages/client/ui-conversation/src/client/input/submission-policy.ts @@ -3,35 +3,39 @@ * preference and resolves keyboard gestures into queue/steer delivery modes; * Host and Agent keep the actual delivery-window authority. */ -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, type SettingsScope, type SnapshotStore, +} from '@deepseek-ai/dsh-client-runtime/client' import type { BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode, } from '../contract/composer-submission.ts' -import { DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts' +import { BUSY_ENTER_FIELD, DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts' +import type { ConversationSettings } from '../../submission-settings.ts' export { DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts' /** - * Persisted policy used by both the composer inject face and its Settings row. + * Busy-Enter policy used by both the composer inject face and its Settings row. * Direct `steer` is intentionally best-effort: AgentLoop turns a closed-window * submission into the next waking Queue item. */ export class ComposerSubmissionPolicy { /** Reactive preference source for the Settings row. */ readonly busyEnter: SnapshotStore = createSnapshotStore(DEFAULT_BUSY_ENTER_BEHAVIOR) - private persist: (behavior: BusyEnterBehavior) => void - - /** @param persist - durable write callback for explicit behavior changes. */ - constructor(persist: (behavior: BusyEnterBehavior) => void = () => {}) { - this.persist = persist - } + private readonly host: SettingsScope | undefined /** - * Bind the owning plugin's durable writer before the policy is exposed. - * @param persist - callback accepting explicit behavior changes. + * @param host - durable preference scope owned by the providing plugin; + * absent compositions stay process-local. The adoption subscription shares + * the scope's plugin lifetime — a disposed scope never publishes again, so + * the policy needs no release hook. */ - bindPersistence(persist: (behavior: BusyEnterBehavior) => void): void { - this.persist = persist + constructor(host?: SettingsScope) { + this.host = host + if (host !== undefined) { + host.subscribe(() => { this.adopt(host) }) + this.adopt(host) + } } /** @@ -53,21 +57,23 @@ export class ComposerSubmissionPolicy { } /** - * Change the plain-Enter behavior used during busy state. + * Change the plain-Enter behavior used during busy state; the live value + * publishes before the durable write starts. * @param behavior - Queue or Steer. */ setBusyEnter(behavior: BusyEnterBehavior): void { if (this.busyEnter.getSnapshot() === behavior) return this.busyEnter.set(behavior) - this.persist(behavior) + void this.host?.set(BUSY_ENTER_FIELD, behavior) } /** - * Apply a Host preference without writing it back. - * @param behavior - validated behavior from settings. + * Adopt the scope's accepted durable behavior without writing it back. + * @param host - the constructor-narrowed scope driving this adoption. */ - syncPreference(behavior: BusyEnterBehavior): void { - if (this.busyEnter.getSnapshot() === behavior) return - this.busyEnter.set(behavior) + private adopt(host: SettingsScope): void { + const section = host.getSnapshot().value + if (section === undefined || this.busyEnter.getSnapshot() === section.busyEnter) return + this.busyEnter.set(section.busyEnter) } } diff --git a/packages/client/ui-conversation/src/index.ts b/packages/client/ui-conversation/src/index.ts index 2377c8a73f..1d36164767 100644 --- a/packages/client/ui-conversation/src/index.ts +++ b/packages/client/ui-conversation/src/index.ts @@ -5,19 +5,16 @@ import z from 'schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, - DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, + DEFAULT_BUSY_ENTER_BEHAVIOR, type ConversationSettings, } from './submission-settings.ts' export { BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, - DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, + DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, type ConversationSettings, } from './submission-settings.ts' -interface ConversationSettings { - busyEnter: BusyEnterBehavior -} - -const ConversationSettingsSchema: z = z.object({ +/** Durable conversation schema; also the wire envelope the browser scope validates against. */ +export const ConversationSettingsSchema: z = z.object({ [BUSY_ENTER_FIELD]: z.union([...BUSY_ENTER_BEHAVIORS]).default(DEFAULT_BUSY_ENTER_BEHAVIOR), }) diff --git a/packages/client/ui-conversation/src/submission-settings.ts b/packages/client/ui-conversation/src/submission-settings.ts index a1ba6e082c..cf19472c47 100644 --- a/packages/client/ui-conversation/src/submission-settings.ts +++ b/packages/client/ui-conversation/src/submission-settings.ts @@ -15,11 +15,8 @@ export type BusyEnterBehavior = typeof BUSY_ENTER_BEHAVIORS[number] /** Default preserves Enter-as-Queue for running conversations. */ export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue' -/** - * Narrow one settings-wire value to a busy-Enter behavior. - * @param value - value crossing the settings boundary. - * @returns whether the value names a supported behavior. - */ -export function isBusyEnterBehavior(value: unknown): value is BusyEnterBehavior { - return BUSY_ENTER_BEHAVIORS.some(behavior => behavior === value) +/** Durable conversation section shared by the Host schema and the browser scope. */ +export interface ConversationSettings { + /** Delivery mode for plain Enter while the addressed agent is busy. */ + busyEnter: BusyEnterBehavior } diff --git a/packages/client/ui-conversation/tests/host.spec.ts b/packages/client/ui-conversation/tests/host.spec.ts index bb16273d64..0d16a23da2 100644 --- a/packages/client/ui-conversation/tests/host.spec.ts +++ b/packages/client/ui-conversation/tests/host.spec.ts @@ -4,7 +4,6 @@ import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-a import { CONVERSATION_SETTINGS_NAMESPACE, DEFAULT_BUSY_ENTER_BEHAVIOR, apply, } from '@deepseek-ai/dsh-client-ui-conversation' -import { isBusyEnterBehavior } from '../src/submission-settings.ts' class MemorySettings extends Settings { readonly writable = true @@ -15,12 +14,6 @@ class MemorySettings extends Settings { } describe('ui-conversation host', () => { - it('narrows settings-wire values to the supported behavior pair', () => { - expect(isBusyEnterBehavior('queue')).toBe(true) - expect(isBusyEnterBehavior('steer')).toBe(true) - expect(isBusyEnterBehavior('later')).toBe(false) - }) - it('registers, validates, and disposes the durable busy-Enter preference', async () => { const ctx = new Context() await ctx.plugin(MemorySettings).await() diff --git a/packages/client/ui-conversation/tests/submission-policy.spec.ts b/packages/client/ui-conversation/tests/submission-policy.spec.ts index 6519e9f9d3..3117c39032 100644 --- a/packages/client/ui-conversation/tests/submission-policy.spec.ts +++ b/packages/client/ui-conversation/tests/submission-policy.spec.ts @@ -1,8 +1,10 @@ // @vitest-environment jsdom import { describe, expect, it, vi } from 'vitest' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR, } from '../src/client/input/submission-policy.ts' +import type { ConversationSettings } from '../src/submission-settings.ts' describe('ComposerSubmissionPolicy', () => { it('defaults to Queue and only applies the preference while running', () => { @@ -16,8 +18,6 @@ describe('ComposerSubmissionPolicy', () => { expect(policy.resolve(true, 'accelerated', false)).toBe('queue') const changed = vi.fn() - const persist = vi.fn() - policy.bindPersistence(persist) policy.busyEnter.subscribe(changed) policy.setBusyEnter('steer') expect(changed).toHaveBeenCalledTimes(1) @@ -25,25 +25,42 @@ describe('ComposerSubmissionPolicy', () => { expect(policy.resolve(true, 'accelerated', true)).toBe('queue') expect(policy.resolve(false, 'enter', true)).toBe('queue') expect(policy.resolve(false, 'accelerated', true)).toBe('queue') - expect(persist).toHaveBeenCalledWith('steer') }) - it('syncs a Host preference without writing it back and leaves an identical write untouched', () => { - const persist = vi.fn() - const policy = new ComposerSubmissionPolicy(persist) - policy.syncPreference('steer') + it('writes an explicit change through the scope after publishing it locally', () => { + const host = stubSettingsScope() + const observed: string[] = [] + let liveBehavior = (): string => 'unconstructed' + const scope: typeof host.scope = { + ...host.scope, + set: (field, value) => { + observed.push(`${field}=${String(value)}:${liveBehavior()}`) + return host.scope.set(field, value) + }, + } + const policy = new ComposerSubmissionPolicy(scope) + liveBehavior = () => policy.busyEnter.getSnapshot() + policy.setBusyEnter('steer') + expect(observed).toEqual(['busyEnter=steer:steer']) + expect(host.set).toHaveBeenCalledWith('busyEnter', 'steer') + expect(host.set).toHaveBeenCalledOnce() + }) + + it('adopts a Host preference without writing it back and leaves an identical write untouched', () => { + const host = stubSettingsScope() + const policy = new ComposerSubmissionPolicy(host.scope) + host.publish({ status: 'ready', value: { busyEnter: 'steer' }, revision: 1, writable: true }) expect(policy.busyEnter.getSnapshot()).toBe('steer') policy.setBusyEnter('steer') - expect(persist).not.toHaveBeenCalled() + expect(host.set).not.toHaveBeenCalled() + host.publish({ value: { busyEnter: 'steer' }, revision: 2 }) + expect(policy.busyEnter.getSnapshot()).toBe('steer') }) - it('publishes the in-memory preference before calling the durable writer', () => { - const policy = new ComposerSubmissionPolicy() - const persist = vi.fn(() => { - expect(policy.busyEnter.getSnapshot()).toBe('steer') - }) - policy.bindPersistence(persist) - policy.setBusyEnter('steer') - expect(persist).toHaveBeenCalledOnce() + it('adopts a section already standing at construction', () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', value: { busyEnter: 'steer' }, revision: 1, writable: true }) + const policy = new ComposerSubmissionPolicy(host.scope) + expect(policy.busyEnter.getSnapshot()).toBe('steer') }) }) diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 05c8a741af..73221b15a7 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -3,13 +3,15 @@ * owns the live theme preference (light/dark/system), resolves `system` through * `prefers-color-scheme`, and publishes immutable snapshots; it never touches * the DOM — ui-layout's presenter consumes the resolved snapshot. The Host - * settings controller loads and stores the preference in the user-settings + * settings scope loads and stores the preference in the user-settings * document. The plugin also registers the Appearance preference row into the * settings General section — the theme feature owns its own settings surface. */ import type { Context } from 'cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import { bindSettingsPreference, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { + bindSettingsScope, type ClientContext, type SettingsScope, +} from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' @@ -18,7 +20,7 @@ import { createAppearanceRowStore } from './settings-store.ts' import { en, zh, type ThemeKey } from './locales.ts' import { DEFAULT_PREFERENCE, isThemePreference, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, - type ThemePreference, + type ThemePreference, type ThemeSettings, } from '../theme-settings.ts' export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' @@ -26,7 +28,7 @@ export type { AppearanceRowState } from './settings-store.ts' export type { ThemeKey } from './locales.ts' export { DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, - type ThemePreference, + type ThemePreference, type ThemeSettings, } from '../theme-settings.ts' /** Namespace owning this feature's settings-row copy. */ @@ -98,21 +100,21 @@ const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([ */ export class ThemeService { private readonly ctx: Context + private readonly host: SettingsScope private themes: ThemeDefinition[] = [...BUILTIN_THEMES] private preference: ThemePreference private revision = 0 private snapshot: ThemeSnapshot private readonly media: MediaQueryList | undefined - private persist: (preference: ThemePreference) => void /** * @param ctx - owning context (change events are emitted on it; the - * media-query listener is released through ctx.effect on dispose). - * @param persist - durable write callback for built-in preferences. + * media-query and scope listeners are released through ctx.effect on dispose). + * @param host - durable preference scope owned by the same plugin. */ - constructor(ctx: Context, persist: (preference: ThemePreference) => void = () => {}) { + constructor(ctx: Context, host: SettingsScope) { this.ctx = ctx - this.persist = persist + this.host = host this.preference = DEFAULT_PREFERENCE // Non-browser runs (node e2e booting the client tree) have no matchMedia. this.media = typeof matchMedia === 'undefined' ? undefined : matchMedia('(prefers-color-scheme: dark)') @@ -128,6 +130,8 @@ export class ThemeService { return () => { media.removeEventListener('change', onChange) } }, 'ui-theme: prefers-color-scheme listener') } + ctx.effect(() => host.subscribe(() => { this.adopt() }), 'ui-theme: settings scope adoption') + this.adopt() } /** @@ -138,18 +142,10 @@ export class ThemeService { return this.snapshot } - /** - * Bind the owning plugin's durable writer before the service is provided. - * @param persist - callback accepting built-in preference changes. - */ - bindPersistence(persist: (preference: ThemePreference) => void): void { - this.persist = persist - } - /** * Switch the theme preference — the only user preference write entry. - * Built-in preferences are persisted and every accepted value emits - * `theme/change`. + * Built-in preferences are written through the settings scope and every + * accepted value emits `theme/change`. * @param id - a registered theme id or `system`; unknown ids throw. */ setTheme(id: string): void { @@ -158,17 +154,15 @@ export class ThemeService { } if (this.preference === id) return this.preference = id as ThemePreference - if (isThemePreference(id)) this.persist(id) + if (isThemePreference(id)) void this.host.set(THEME_PREFERENCE_FIELD, id) this.publish() } - /** - * Apply a preference read from Host settings without writing it back. - * @param preference - validated durable preference. - */ - syncPreference(preference: ThemePreference): void { - if (this.preference === preference) return - this.preference = preference + /** Adopt the scope's accepted durable preference without writing it back. */ + private adopt(): void { + const section = this.host.getSnapshot().value + if (section === undefined || this.preference === section.preference) return + this.preference = section.preference this.publish() } @@ -231,14 +225,8 @@ export const inject = ['slots', 'locale', 'connection'] * @param ctx - client cordis context. */ export function apply(ctx: ClientContext): void { - const theme = new ThemeService(ctx) - const controller = bindSettingsPreference(ctx, { - namespace: THEME_SETTINGS_NAMESPACE, - field: THEME_PREFERENCE_FIELD, - decode: value => isThemePreference(value) ? value : undefined, - sync: (preference) => { theme.syncPreference(preference) }, - }) - theme.bindPersistence((preference) => { void controller.persist(preference) }) + const host = bindSettingsScope(ctx, { namespace: THEME_SETTINGS_NAMESPACE }) + const theme = new ThemeService(ctx, host) ctx.provide('theme', theme) ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index 32d3689950..785e6ca898 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -5,19 +5,16 @@ import z from 'schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, - type ThemePreference, + type ThemeSettings, } from './theme-settings.ts' export { DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, - type ThemePreference, + type ThemePreference, type ThemeSettings, } from './theme-settings.ts' -interface ThemeSettings { - preference: ThemePreference -} - -const ThemeSettingsSchema: z = z.object({ +/** Durable theme schema; also the wire envelope the browser scope validates against. */ +export const ThemeSettingsSchema: z = z.object({ [THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE), }) diff --git a/packages/client/ui-theme/src/invariant.ts b/packages/client/ui-theme/src/invariant.ts index e15985a9dc..51667dc5a9 100644 --- a/packages/client/ui-theme/src/invariant.ts +++ b/packages/client/ui-theme/src/invariant.ts @@ -15,10 +15,10 @@ export const name = 'client-ui-theme-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the settings seam validates and publishes the durable + * No runtime invariant: the settings scope validates and publishes the durable * theme section, while the registry emits `theme/change` synchronously with * its own mutations. Store/registry agreement is covered directly by this - * package's Host, controller, and service behavior specs. + * package's Host, scope, and service behavior specs. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-theme/src/theme-settings.ts b/packages/client/ui-theme/src/theme-settings.ts index ca06ec28a7..1cf3a46808 100644 --- a/packages/client/ui-theme/src/theme-settings.ts +++ b/packages/client/ui-theme/src/theme-settings.ts @@ -15,6 +15,12 @@ export type ThemePreference = typeof THEME_PREFERENCES[number] /** Default preference when the user-settings document has no override. */ export const DEFAULT_PREFERENCE: ThemePreference = 'system' +/** Durable theme section shared by the Host schema and the browser scope. */ +export interface ThemeSettings { + /** Selected built-in preference. */ + preference: ThemePreference +} + /** * Narrow one wire or registry value to a persistable preference. * @param value - value crossing the settings or registry boundary. diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index d134340560..c92bf47e85 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -10,6 +10,7 @@ import { apply, inject, SETTINGS_NS, THEME_SETTINGS_NAMESPACE, } from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' +import { ThemeSettingsSchema } from '@deepseek-ai/dsh-client-ui-theme' import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore } from '../src/client/settings-store.ts' @@ -33,7 +34,7 @@ async function bench(isLoopback = true) { let preference = 'system' const namespace = () => ({ ns: THEME_SETTINGS_NAMESPACE, - schema: {}, + schema: ThemeSettingsSchema.toJSON(), value: { preference }, applies: 'live' as const, secrets: [], diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index f6d8a7ff62..b7fc3bd17a 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -1,19 +1,20 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' +import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import type { ThemeSettings, ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' -const make = (persist = vi.fn()): { +const make = (host = stubSettingsScope()): { ctx: Context theme: ThemeService events: ThemeSnapshot[] - persist: typeof persist + host: StubSettingsScope } => { const ctx = new Context() const events: ThemeSnapshot[] = [] ctx.on('theme/change', (snapshot) => { events.push(snapshot) }) - return { ctx, theme: new ThemeService(ctx, persist), events, persist } + return { ctx, theme: new ThemeService(ctx, host.scope), events, host } } describe('ThemeService', () => { @@ -27,12 +28,12 @@ describe('ThemeService', () => { expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark']) }) - it('setTheme switches, requests persistence, republishes, and keeps DOM untouched', () => { - const { theme, events, persist } = make() + it('setTheme switches, writes through the scope, republishes, and keeps DOM untouched', () => { + const { theme, events, host } = make() theme.setTheme('dark') expect(theme.getTheme().preference).toBe('dark') expect(theme.getTheme().active.colorScheme).toBe('dark') - expect(persist).toHaveBeenCalledWith('dark') + expect(host.set).toHaveBeenCalledWith('preference', 'dark') expect(events).toHaveLength(1) expect(events[0]).toBe(theme.getTheme()) // The service never touches presentation state. @@ -40,19 +41,26 @@ describe('ThemeService', () => { // Same-value set is a no-op (no extra event). theme.setTheme('dark') expect(events).toHaveLength(1) - expect(persist).toHaveBeenCalledOnce() + expect(host.set).toHaveBeenCalledOnce() }) - it('syncs a Host preference without writing it back', () => { - const { theme, events, persist } = make() - theme.syncPreference('dark') + it('adopts a published Host section without writing it back', () => { + const { theme, events, host } = make() + host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true }) expect(theme.getTheme().preference).toBe('dark') expect(events).toHaveLength(1) - expect(persist).not.toHaveBeenCalled() - theme.syncPreference('dark') + expect(host.set).not.toHaveBeenCalled() + host.publish({ value: { preference: 'dark' }, revision: 2 }) expect(events).toHaveLength(1) }) + it('adopts a section already standing at construction', () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true }) + const { theme } = make(host) + expect(theme.getTheme().preference).toBe('dark') + }) + it('throws on unknown setTheme ids, duplicate registration, and the system id', () => { const { theme } = make() expect(() => { theme.setTheme('sepia') }).toThrow('not registered') @@ -61,7 +69,7 @@ describe('ThemeService', () => { }) it('registered themes join the snapshot; disposing the active one resets to default', () => { - const { theme, events, persist } = make() + const { theme, events, host } = make() const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: { '--dsw-alias-bg-base': 'red' } }) expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark', 'sepia']) theme.setTheme('sepia') @@ -71,7 +79,7 @@ describe('ThemeService', () => { expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark']) // Custom ids are in-process extension themes; only the built-in product // preferences cross the Host settings schema. - expect(persist).not.toHaveBeenCalled() + expect(host.set).not.toHaveBeenCalled() // register + set + dispose = three publishes; disposer is idempotent. expect(events.length).toBe(3) dispose() @@ -95,11 +103,11 @@ describe('ThemeService', () => { expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4]) }) - it('uses a no-op persistence callback when constructed directly', () => { - const ctx = new Context() - const theme = new ThemeService(ctx) - theme.setTheme('dark') - expect(theme.getTheme().preference).toBe('dark') + it('context dispose releases the scope subscription', async () => { + const { ctx, host } = make() + expect(host.listenerCount()).toBe(1) + await ctx.fiber.dispose() + expect(host.listenerCount()).toBe(0) }) describe('prefers-color-scheme resolution (stubbed matchMedia)', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f72439c8d4..2f90a138e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1351,6 +1351,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection + '@deepseek-ai/dsh-client-schema-form': + specifier: workspace:^ + version: link:../schema-form '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -1400,6 +1403,9 @@ importers: cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery packages/client/schema-form: dependencies: diff --git a/vitest.config.ts b/vitest.config.ts index 84e273f0da..20768618f6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -160,9 +160,9 @@ export default defineConfig({ 'packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx', 'packages/client/ui-workspace/src/client/WorkspacePicker.tsx', 'packages/client/web-react/src/*', - // This isolated scalar-settings lifecycle has complete unit coverage; + // This isolated settings-scope lifecycle has complete unit coverage; // keep it out of the broader client-runtime GUI debt exemption. - 'packages/client/runtime/src/**/!(settings-preference).ts', + 'packages/client/runtime/src/**/!(settings-scope).ts', // Keep the browser conversation tree under its existing GUI debt // exemption while gating the newly stateful Host half and vocabulary. 'packages/client/ui-conversation/src/client/*', From 53c66ecbebd21e3d589660d8afc156ccc9809cc4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 23:30:04 +0800 Subject: [PATCH 047/229] refactor(client): keep settings constants and schemas off the client contract surfaces The /client entry of a UI plugin exports no values beyond what cordis loading needs; the theme constants block returns to type-only re-exports, the per-namespace schemas move into the shared *-settings modules instead of widening the host entries, and same-package specs import those internals directly per the export discipline in packages/client/AGENTS.md. --- packages/client/locale/src/index.ts | 10 +--------- packages/client/locale/src/locale-settings.ts | 7 +++++++ packages/client/locale/tests/apply.spec.ts | 3 +-- packages/client/ui-conversation/src/index.ts | 11 +---------- .../client/ui-conversation/src/submission-settings.ts | 7 +++++++ packages/client/ui-theme/src/client/index.ts | 5 +---- packages/client/ui-theme/src/index.ts | 11 +---------- packages/client/ui-theme/src/theme-settings.ts | 7 +++++++ packages/client/ui-theme/tests/apply.spec.ts | 6 ++---- 9 files changed, 28 insertions(+), 39 deletions(-) diff --git a/packages/client/locale/src/index.ts b/packages/client/locale/src/index.ts index 3001890569..c8d7ed9f95 100644 --- a/packages/client/locale/src/index.ts +++ b/packages/client/locale/src/index.ts @@ -1,22 +1,14 @@ /** Host registration for the browser locale preference. */ import type { Context } from 'cordis' -import z from 'schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { - LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleSettings, -} from './locale-settings.ts' +import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from './locale-settings.ts' export { LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings, } from './locale-settings.ts' -/** Durable locale schema; also the wire envelope the browser scope validates against. */ -export const LocaleSettingsSchema: z = z.object({ - [LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false), -}) - /** * Register the durable locale section when a settings provider exists. * @param ctx - Host context whose optional settings service owns the section. diff --git a/packages/client/locale/src/locale-settings.ts b/packages/client/locale/src/locale-settings.ts index 90459981fa..c5d0399f86 100644 --- a/packages/client/locale/src/locale-settings.ts +++ b/packages/client/locale/src/locale-settings.ts @@ -1,5 +1,7 @@ /** Locale preference stored in the Host user-settings document. */ +import z from 'schemastery' + /** Settings namespace owned by the locale plugin. */ export const LOCALE_SETTINGS_NAMESPACE = 'locale' @@ -17,3 +19,8 @@ export interface LocaleSettings { /** Explicit locale selection; absence delegates to the browser. */ preference?: LocaleId } + +/** Durable locale schema; also the wire envelope the browser scope validates against. */ +export const LocaleSettingsSchema: z = z.object({ + [LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false), +}) diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index 152d0e6987..84f4299ef2 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -8,8 +8,7 @@ import { apply, inject, SETTINGS_NS, } from '@deepseek-ai/dsh-client-locale/client' import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client' -import { LOCALE_SETTINGS_NAMESPACE } from '../src/locale-settings.ts' -import { LocaleSettingsSchema } from '../src/index.ts' +import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '../src/locale-settings.ts' import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { createLanguageRowStore } from '../src/client/settings-store.ts' diff --git a/packages/client/ui-conversation/src/index.ts b/packages/client/ui-conversation/src/index.ts index 1d36164767..b49d7dcf0d 100644 --- a/packages/client/ui-conversation/src/index.ts +++ b/packages/client/ui-conversation/src/index.ts @@ -1,23 +1,14 @@ /** Host registration for browser conversation preferences. */ import type { Context } from 'cordis' -import z from 'schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { - BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, - DEFAULT_BUSY_ENTER_BEHAVIOR, type ConversationSettings, -} from './submission-settings.ts' +import { CONVERSATION_SETTINGS_NAMESPACE, ConversationSettingsSchema } from './submission-settings.ts' export { BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, type ConversationSettings, } from './submission-settings.ts' -/** Durable conversation schema; also the wire envelope the browser scope validates against. */ -export const ConversationSettingsSchema: z = z.object({ - [BUSY_ENTER_FIELD]: z.union([...BUSY_ENTER_BEHAVIORS]).default(DEFAULT_BUSY_ENTER_BEHAVIOR), -}) - /** * Register the durable conversation section when a settings provider exists. * @param ctx - Host context whose optional settings service owns the section. diff --git a/packages/client/ui-conversation/src/submission-settings.ts b/packages/client/ui-conversation/src/submission-settings.ts index cf19472c47..0bd42d33cf 100644 --- a/packages/client/ui-conversation/src/submission-settings.ts +++ b/packages/client/ui-conversation/src/submission-settings.ts @@ -1,5 +1,7 @@ /** Busy-Enter preference stored in the Host user-settings document. */ +import z from 'schemastery' + /** Settings namespace owned by the conversation plugin. */ export const CONVERSATION_SETTINGS_NAMESPACE = 'ui-conversation' @@ -20,3 +22,8 @@ export interface ConversationSettings { /** Delivery mode for plain Enter while the addressed agent is busy. */ busyEnter: BusyEnterBehavior } + +/** Durable conversation schema; also the wire envelope the browser scope validates against. */ +export const ConversationSettingsSchema: z = z.object({ + [BUSY_ENTER_FIELD]: z.union([...BUSY_ENTER_BEHAVIORS]).default(DEFAULT_BUSY_ENTER_BEHAVIOR), +}) diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 73221b15a7..8ef7f3fee7 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -26,10 +26,7 @@ import { export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' export type { AppearanceRowState } from './settings-store.ts' export type { ThemeKey } from './locales.ts' -export { - DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, - type ThemePreference, type ThemeSettings, -} from '../theme-settings.ts' +export type { ThemePreference, ThemeSettings } from '../theme-settings.ts' /** Namespace owning this feature's settings-row copy. */ export const SETTINGS_NS = 'settings.theme' diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index 785e6ca898..576028d37d 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -1,23 +1,14 @@ /** Host registration for the browser theme preference. */ import type { Context } from 'cordis' -import z from 'schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { - DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, - type ThemeSettings, -} from './theme-settings.ts' +import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from './theme-settings.ts' export { DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, type ThemePreference, type ThemeSettings, } from './theme-settings.ts' -/** Durable theme schema; also the wire envelope the browser scope validates against. */ -export const ThemeSettingsSchema: z = z.object({ - [THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE), -}) - /** * Register the durable theme section when a settings provider exists. * @param ctx - Host context whose optional settings service owns the section. diff --git a/packages/client/ui-theme/src/theme-settings.ts b/packages/client/ui-theme/src/theme-settings.ts index 1cf3a46808..d7fc966031 100644 --- a/packages/client/ui-theme/src/theme-settings.ts +++ b/packages/client/ui-theme/src/theme-settings.ts @@ -1,5 +1,7 @@ /** Theme preferences stored in the Host user-settings document. */ +import z from 'schemastery' + /** Built-in preferences accepted at the registry and settings boundaries. */ export const THEME_PREFERENCES = ['light', 'dark', 'system'] as const @@ -21,6 +23,11 @@ export interface ThemeSettings { preference: ThemePreference } +/** Durable theme schema; also the wire envelope the browser scope validates against. */ +export const ThemeSettingsSchema: z = z.object({ + [THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE), +}) + /** * Narrow one wire or registry value to a persistable preference. * @param value - value crossing the settings or registry boundary. diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index c92bf47e85..25c2ac14df 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -6,11 +6,9 @@ import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' -import { - apply, inject, SETTINGS_NS, THEME_SETTINGS_NAMESPACE, -} from '@deepseek-ai/dsh-client-ui-theme/client' +import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' -import { ThemeSettingsSchema } from '@deepseek-ai/dsh-client-ui-theme' +import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts' import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore } from '../src/client/settings-store.ts' From 5ed934935a564eaf5806ee8c102c92ede8fe599d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 02:00:28 +0800 Subject: [PATCH 048/229] test(llm): follow credential resolver after master merge --- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 90a757410b..3e0bed6c8b 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -236,7 +236,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, + providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } }, }) await ctx.plugin(LateAttachmentStore) From 16c1eaf546d4d59d9e8786d96280bd724665104a Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:05:27 -0700 Subject: [PATCH 049/229] fix(fs-local): bound overwrite contextual diff bases Rebuild of the fs-overwrite-diff-bound branch on current master. Adds the diffBasisMaxBytes Config field (10 MiB default, capped by runtime allocation/decode limits), gates both overwrite sides, and reads the prior basis from the bounded opened descriptor in cancellation-aware chunks; any post-stat size change returns a null basis. Also pins the one-extra-byte growth probe with a regression and drops the now-covered v8 ignore. --- ...-30-bounded-overwrite-diff-basis.i18n.yaml | 6 + ...2026-07-30-bounded-overwrite-diff-basis.md | 31 +++ ...6-07-30-bounded-overwrite-diff-basis.zh.md | 31 +++ docs/config-catalog.md | 15 +- .../core-data-structures/filesystem.i18n.yaml | 4 +- docs/core-data-structures/filesystem.md | 9 +- docs/core-data-structures/filesystem.zh.md | 9 +- packages/fs/fs-local/README.i18n.yaml | 4 +- packages/fs/fs-local/README.md | 4 +- packages/fs/fs-local/README.zh.md | 4 +- packages/fs/fs-local/src/fsio.ts | 57 ++++- packages/fs/fs-local/src/index.ts | 35 ++- packages/fs/fs-local/tests/filesystem.spec.ts | 63 ++++++ packages/fs/fs-local/tests/fsio.spec.ts | 202 +++++++++++++++++- packages/fs/fs-sandbox/README.i18n.yaml | 4 +- packages/fs/fs-sandbox/README.md | 2 + packages/fs/fs-sandbox/README.zh.md | 2 + packages/fs/fs-sandbox/src/index.ts | 8 +- packages/fs/fs/src/types.ts | 9 +- 19 files changed, 452 insertions(+), 47 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml new file mode 100644 index 0000000000..f7361eda32 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md +2026-07-30-bounded-overwrite-diff-basis.md: 353b538a12b8cf48dfa3a561c62d6d8ab9a8bfcf +2026-07-30-bounded-overwrite-diff-basis.zh.md: e2b473a21d16dc47b5b8bf781b8ddffdf443955b diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md new file mode 100644 index 0000000000..353b538a12 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md @@ -0,0 +1,31 @@ +# Agent Note: Bound overwrite contextual-diff bases at the provider + +Status: implemented + +English | [中文](2026-07-30-bounded-overwrite-diff-basis.zh.md) + +## Problem + +`dsh-fs-local` returned the complete prior file in `FsWriteOutcome.before` so consumers could build a contextual overwrite diff. That presentation-only pre-read was unbounded: a large overwrite could allocate the entire prior file, and checking an earlier path stat alone could not enforce a limit because an external process could replace or grow the file between the stat and the read. A large replacement also made the contextual hunk approach the replacement size even when the prior file was small. This closes the deferred bound recorded by [result-time applied-hunk diffs](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md). + +## Decision + +`LocalFileSystem.Config.diffBasisMaxBytes` is a positive safe-integer deployment setting no greater than the runtime's Buffer-allocation and string-decoding limits, with a 10 MiB default. An overwrite supplies `before` only when the UTF-8 replacement is strictly below that limit and the prior file opened for the basis also ends below it. The prior read opens a descriptor, checks that descriptor, and reads at most the configured byte count in cancellation-aware chunks; reaching the boundary returns `null`. A size change after descriptor stat also returns `null`, even if the final size remains below the limit, because a partial prefix would be an incorrect diff basis. Binary or invalid UTF-8 prior content likewise returns `null`. These outcomes do not block the atomic write. + +The local provider owns this decision because `before` is its optional, best-effort basis: it can avoid acquiring prior content that the configured pair limit has already made ineligible. `tool-fs` continues to own diff computation, retention, and presentation. The setting is independent of `tool-fs.readStreamMinSize`; read routing and overwrite presentation are different policies and need not share a value. + +`before: null` asks consumers to use their existing whole-file fallback. The limit bounds only the extra prior-content acquisition and eligibility for a contextual pair. It does not bound the caller-owned replacement, the returned `after` value, or a consumer's fallback rendering. + +## Alternatives considered + +**Keep a hardcoded threshold equal to the read tool's streaming threshold.** Rejected because the read threshold is deployment-configurable and consumer-owned. Two same-valued constants would create an unenforced cross-package coupling, while the overwrite basis is itself a deployment memory/presentation choice. + +**Gate only the prior side in the provider and cap new-content diffing in `tool-fs`.** Rejected because it would acquire prior text even when the provider's configured pair limit already excludes the replacement, and it would split one `before` eligibility rule across two plugins. Consumers remain free to impose additional output limits. + +**Trust the initial `probe()` size before using an ordinary whole-file read.** Rejected because that size can become stale before the read. The descriptor reader must enforce the bound on the object it actually reads. + +**Stream a contextual diff for arbitrarily large pairs.** Rejected for this bug fix because the current filesystem seam returns complete `before`/`after` strings and the current diff implementation consumes them. A streaming diff would require a separate cross-package protocol and presentation design. + +## Consequences + +Deployments can tune the extra overwrite-basis cost without changing read routing. At or above the exclusive limit, overwrites still succeed and remain visible through the whole-file fallback, but lose contextual hunks. Below the limit, the provider can still hold almost `diffBasisMaxBytes` of prior text in addition to the caller's replacement. The bounded descriptor read adds an open/stat/read sequence for eligible overwrites, while preventing a stale path probe from turning that sequence into an unbounded allocation. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md new file mode 100644 index 0000000000..e2b473a21d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 在提供方限制覆写上下文 diff 基础 + +Status: implemented + +[English](2026-07-30-bounded-overwrite-diff-basis.md) | 中文 + +## Problem + +`dsh-fs-local` 会在 `FsWriteOutcome.before` 中返回完整旧文件,供消费方生成覆写上下文 diff。这个仅用于展示的预读没有上限:大文件覆写可能分配整个旧文件;而仅检查较早的路径 stat 也无法真正实施上限,因为外部进程可以在 stat 与读取之间替换文件或扩大文件。即使旧文件很小,大替换内容也会使上下文 hunk 接近替换内容本身的大小。本改动关闭了 [result-time applied-hunk diff](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md) 中记录的暂缓上限事项。 + +## Decision + +`LocalFileSystem.Config.diffBasisMaxBytes` 是一个不超过运行时 Buffer 分配和字符串解码上限的正安全整数部署配置,默认 10 MiB。只有当 UTF-8 替换内容严格低于该上限,且为生成基础而打开的旧文件最终也低于该上限时,覆写才提供 `before`。旧文件读取会打开文件描述符、检查该描述符,并按可响应取消的分块最多读取配置的字节数;一旦到达边界便返回 `null`。描述符 stat 后发生大小变化时同样返回 `null`,即使最终大小仍低于上限,因为部分前缀会成为错误的 diff 基础。旧内容为二进制或无效 UTF-8 时也返回 `null`。这些结果都不会阻止原子写入。 + +本地提供方拥有该决策,因为 `before` 是它提供的可选、尽力而为的基础:当配置的成对上限已使替换内容不合格时,它可以避免获取旧内容。`tool-fs` 继续拥有 diff 计算、保留与展示。该配置独立于 `tool-fs.readStreamMinSize`;读取路由与覆写展示是不同策略,无需共享数值。 + +`before: null` 要求消费方使用既有的整文件回退。该上限只限制额外获取旧内容的成本,以及上下文内容对是否合格;它不限制调用方持有的替换内容、返回的 `after` 值或消费方的回退渲染。 + +## Alternatives considered + +**保留一个与读取工具流式阈值相等的硬编码阈值。** 否决,因为读取阈值可由部署配置,且归消费方所有。两个同值常量会形成无法强制的一致性耦合,而覆写基础本身也是部署层面的内存与展示选择。 + +**提供方只限制旧内容一侧,并在 `tool-fs` 中限制新内容 diff。** 否决,因为当提供方配置的成对上限已经排除替换内容时,这仍会获取旧文本;同时会把同一条 `before` 合格规则拆到两个插件中。消费方仍可自由施加额外的输出限制。 + +**信任初次 `probe()` 的大小,再执行普通整文件读取。** 否决,因为该大小可能在读取前变旧;描述符读取必须对它真正读取的对象实施上限。 + +**为任意大的内容对流式生成上下文 diff。** 本次缺陷修复不采用,因为当前文件系统 seam 返回完整的 `before`/`after` 字符串,当前 diff 实现也消费这两个字符串。流式 diff 需要独立的跨包协议与展示设计。 + +## Consequences + +部署可以调整额外的覆写基础成本,而不改变读取路由。达到或超过排他上限时,覆写仍会成功,并通过整文件回退保持可见,但不再提供上下文 hunk。低于上限时,除调用方的替换内容外,提供方仍可能持有接近 `diffBasisMaxBytes` 的旧文本。对于合格覆写,有上限的描述符读取会增加一次 open/stat/read 序列,同时防止陈旧路径探测把该序列变成无上限分配。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index feb8aa9d86..f3a59d4ec4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -460,10 +460,15 @@ Source: [`packages/host/frontend-static/src/index.ts:28`](../packages/host/front export interface Config { /** Base directory for relative paths. Defaults to `process.cwd()`. */ cwd?: string + /** + * Exclusive UTF-8 byte limit on each overwrite-diff side, capped by the + * runtime's safe allocation/decode maximum. Defaults to 10 MiB. + */ + diffBasisMaxBytes?: number } ``` -Source: [`packages/fs/fs-local/src/index.ts:38`](../packages/fs/fs-local/src/index.ts) +Source: [`packages/fs/fs-local/src/index.ts:39`](../packages/fs/fs-local/src/index.ts) ## `@deepseek-ai/dsh-fs-sandbox` @@ -471,10 +476,10 @@ Requires: `sandboxPolicy` ```ts config-catalog /** - * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve - * base for relative paths). The sandbox default (mode + `workspace-write` - * fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling - * session for every enforcing capability. + * Plugin config: the local backend's knobs verbatim (`cwd` resolution default + * and `diffBasisMaxBytes` overwrite-presentation bound). The sandbox default + * (mode + `workspace-write` fallback root) is NOT here — `ctx.sandboxPolicy` + * resolves each calling session for every enforcing capability. */ export type Config = LocalConfig ``` diff --git a/docs/core-data-structures/filesystem.i18n.yaml b/docs/core-data-structures/filesystem.i18n.yaml index 91ac0ed81d..a4a42e72d2 100644 --- a/docs/core-data-structures/filesystem.i18n.yaml +++ b/docs/core-data-structures/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/core-data-structures/filesystem.md -filesystem.md: 110c1fd428b15c5094f9dcc94050cad61c324373 -filesystem.zh.md: 010ec22a5d4a29555425c3deede08b317cba7004 +filesystem.md: 4862a25e3b8922a8709b4b025acd5436a2228082 +filesystem.zh.md: 2b5c7abec1555cc2cbb909eaae60d9022ae297dd diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 110c1fd428..4862a25e3b 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -134,10 +134,11 @@ interface FsWriteOutcome { version: FsVersion /** * The file's content BEFORE the write, or `null` when the file did not exist - * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text - * (the diff basis), never a diff — a consumer computes the result-time - * contextual diff from `before`/`after` when `before` is present, else falls - * back to a whole-file diff. + * (a create) or the backend declined a contextual basis (for example, a + * binary/non-UTF-8 prior file or either overwrite side reaching its exclusive limit). + * LF-normalized storage text (the diff basis), never a diff — a consumer + * computes the result-time contextual diff from `before`/`after` when + * `before` is present, else falls back to a whole-file diff. */ before: string | null /** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */ diff --git a/docs/core-data-structures/filesystem.zh.md b/docs/core-data-structures/filesystem.zh.md index 010ec22a5d..2b5c7abec1 100644 --- a/docs/core-data-structures/filesystem.zh.md +++ b/docs/core-data-structures/filesystem.zh.md @@ -134,10 +134,11 @@ interface FsWriteOutcome { version: FsVersion /** * The file's content BEFORE the write, or `null` when the file did not exist - * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text - * (the diff basis), never a diff — a consumer computes the result-time - * contextual diff from `before`/`after` when `before` is present, else falls - * back to a whole-file diff. + * (a create) or the backend declined a contextual basis (for example, a + * binary/non-UTF-8 prior file or either overwrite side reaching its exclusive limit). + * LF-normalized storage text (the diff basis), never a diff — a consumer + * computes the result-time contextual diff from `before`/`after` when + * `before` is present, else falls back to a whole-file diff. */ before: string | null /** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */ diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index cc14be584f..cbc0c56619 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: 6d344fa3fef7f6bda6c0daa50184661156a925a7 -README.zh.md: 4c94de64561d805684f91f02d5b0dfc375f0d04f +README.md: 9efee1ed3c33c825b20b4565f3c6cef7200ce4a2 +README.zh.md: 5554017f3d528e25decffe2f866c843039bb0457 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 6d344fa3fe..9efee1ed3c 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -18,7 +18,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`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` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. 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`) decides which to call by size and owns the line windowing. - **`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, fsyncs, then renames over the target. 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` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). +- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. 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` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). An overwrite returns the prior text as its contextual diff basis only when both the opened prior file and UTF-8 replacement are strictly below `config.diffBasisMaxBytes` (default 10 MiB). The descriptor read enforces that limit even if an external writer replaces or changes the file size after the initial probe. Otherwise the provider returns `before: null`, so presentation uses its whole-file fallback. - **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. @@ -34,8 +34,8 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work - **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall ([capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences)). -- **An overwrite reads the whole prior file into memory** — solely as the UI diff basis; bounding that pre-read above a size threshold is deferred (`TODO(overwrite-diff-bound)`). - **Version tokens are `mtimeMs:size`** — an external change that preserves both within the filesystem's timestamp granularity defeats the stale guard. - **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path. +- **A sub-limit overwrite still buffers a contextual basis** — `writeText` may retain up to just below `config.diffBasisMaxBytes` of prior text in addition to the caller-owned replacement; the bound does not cap the returned `after` value or presentation's whole-file fallback. - **Binary detection is asymmetric** — reads NUL-sample only the first 8192 bytes while edits scan the whole buffer, so a file with a late NUL reads fine but rejects edits. - **The per-target mutation lock is in-process only** — a writer in another process is caught only by the optional version guard, never serialized. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index 4c94de6456..5554017f3d 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -18,7 +18,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`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`)按大小决定调用哪个方法,并负责行窗口逻辑。 - **`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 后,以 rename 覆盖目标。现有文件的 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` 防护是可选(OPTIONAL)的:省略时无条件创建或覆盖;`createIfAbsent` 创建缺失目标并拒绝现有目标(`FS_NOT_OBSERVED`);`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。 +- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内。完成写入和 fsync 后,以 rename 覆盖目标。现有文件的 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` 防护是可选(OPTIONAL)的:省略时无条件创建或覆盖;`createIfAbsent` 创建缺失目标并拒绝现有目标(`FS_NOT_OBSERVED`);`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。仅当打开后的旧文件和 UTF-8 替换内容都严格低于 `config.diffBasisMaxBytes`(默认 10 MiB)时,覆写才返回旧文本作为上下文 diff 基础。即使外部写入方在初次探测后替换文件或改变文件大小,文件描述符读取仍会强制执行该上限;否则提供方返回 `before: null`,由展示层使用整文件回退。 - **`editText`**:在同一原语之上执行原子式的字面量读取-修改-写入,并通过变更锁按目标串行化。`expected` 防护是可选(OPTIONAL)的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `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`。 包根目录的 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis,单独进行单元测试);`src/index.ts` 是轻量服务接线。 @@ -34,8 +34,8 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) ## 已知限制与暂缓事项 - **`config.cwd` 不是沙箱**:它是解析默认值,而非约束;绝对路径和 `..` 可以逃逸。请使用更严格的 `ctx.fs` 后端或 `tools/execute` waterfall(瀑布式事件)上的权限插件实施约束(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences))。 -- **覆盖会把整个旧文件读入内存**:只用于 UI diff;在大小阈值之上限制这次预读取的工作延期处理(`TODO(overwrite-diff-bound)`)。 - **版本 token 是 `mtimeMs:size`**:如果外部变更在文件系统时间戳粒度内保持两者不变,就能绕过陈旧防护。 - **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。 +- **低于上限的覆写仍会缓冲上下文基础**:`writeText` 除调用方持有的替换内容外,最多还会保留略低于 `config.diffBasisMaxBytes` 的旧文本;该上限不限制返回的 `after` 值,也不限制展示层的整文件回退。 - **二进制检测不对称**:读取只对前 8192 字节执行 NUL 采样,编辑则扫描整个 buffer,因此 NUL 出现在后部的文件可以读取,但编辑会被拒绝。 - **每目标变更锁仅限进程内**:其他进程中的写入方只会被可选版本防护发现,绝不会被串行化。 diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index c93e4ddaeb..b47d6f2bcd 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -15,6 +15,8 @@ import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts' const BINARY_SAMPLE_BYTES = 8192 +// Bound one non-abortable FileHandle.read so cancellation is observed between chunks. +const DIFF_BASIS_READ_CHUNK_BYTES = 64 * 1024 function isENOENT(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' @@ -70,9 +72,8 @@ function versionOf(info: BigIntStats): FsVersion { } /** - * Test seam: lets specs pin the atomic-write temp names (to prove - * exclusive-open behavior without a name race) and observe the staged temp - * file before it is renamed over the target. + * Test seam: lets specs pin the atomic-write temp names (to prove exclusive-open behavior without + * a name race), override native boundaries, and observe the staged temp file before publication. */ export interface FsIoInternals { /** Override the host platform for native-publication unit coverage. */ @@ -564,17 +565,53 @@ export async function readForEdit( } /** - * Best-effort overwrite diff basis. Binary or invalid UTF-8 returns `null` so the write still - * succeeds and presentation falls back to a whole-file diff. + * Best-effort overwrite diff basis. Binary, invalid UTF-8, or a file at/above the byte limit + * returns `null` so the write still succeeds and presentation falls back to a whole-file diff. + * The bound is enforced on the opened descriptor rather than a prior path stat, so concurrent + * external replacement or size changes cannot make this helper buffer more than `maxBytes`. * @param absolutePath - the file to read (typically a target key); it must exist. + * @param maxBytes - exclusive upper bound for bytes held as the contextual-diff basis. * @param signal - aborts the read (`FS_ABORTED`). - * @returns the LF-normalized text, or null for a binary or non-UTF-8 file. + * @returns the LF-normalized text, or null for a non-regular, at/above-limit, binary, non-UTF-8, + * or descriptor-size-changed file. */ -export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise { - const buffer = await readFileAbortable(absolutePath, 'read', signal) - if (buffer.includes(0)) return null +export async function readTextForDiff( + absolutePath: string, + maxBytes: number, + signal?: AbortSignal, +): Promise { + throwIfAborted(signal, 'read') + const handle = await open(absolutePath, 'r') + let buffer: Buffer + let total = 0 + let openedSize = 0 try { - return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(buffer)) + throwIfAborted(signal, 'read') + const info = await handle.stat() + throwIfAborted(signal, 'read') + /* v8 ignore next -- requires a post-preflight replacement with a non-file; + * direct coverage is not portable to Windows. */ + if (!info.isFile()) return null + if (info.size >= maxBytes) return null + openedSize = info.size + // One extra byte detects growth after stat without retaining per-read backing buffers. + buffer = Buffer.allocUnsafe(openedSize + 1) + while (total < buffer.length) { + throwIfAborted(signal, 'read') + const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES) + const { bytesRead } = await handle.read(buffer, total, length, null) + if (bytesRead === 0) break + total += bytesRead + } + } finally { + await handle.close() + } + throwIfAborted(signal, 'read') + if (total !== openedSize) return null + const basis = buffer.subarray(0, total) + if (basis.includes(0)) return null + try { + return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(basis)) } catch (error: unknown) { /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */ if (!(error instanceof TypeError)) throw error diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 18433f3f7c..33a1c354b6 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -5,6 +5,7 @@ */ import { Context } from 'cordis' +import { constants as bufferConstants } from 'node:buffer' import { resolve } from 'node:path' import z from 'schemastery' import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' @@ -38,9 +39,19 @@ import type { FsIoInternals } from './fsio.ts' export interface Config { /** Base directory for relative paths. Defaults to `process.cwd()`. */ cwd?: string + /** + * Exclusive UTF-8 byte limit on each overwrite-diff side, capped by the + * runtime's safe allocation/decode maximum. Defaults to 10 MiB. + */ + diffBasisMaxBytes?: number } type ResolvedConfig = Required +const DEFAULT_DIFF_BASIS_MAX_BYTES = 10 * 1024 * 1024 +const MAX_DIFF_BASIS_BYTES = Math.min( + bufferConstants.MAX_LENGTH, + bufferConstants.MAX_STRING_LENGTH, +) /** * The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd} @@ -51,11 +62,12 @@ type ResolvedConfig = Required export class LocalFileSystem extends FileSystem { static Config: z = z.object({ cwd: z.string().default(process.cwd()), + diffBasisMaxBytes: z.number().default(DEFAULT_DIFF_BASIS_MAX_BYTES), }) /** Validated config (schemastery applied the defaults before construction). */ readonly config: ResolvedConfig - /** Test seam forwarded to fsio (force streaming path, pin temp names). */ + /** Test seam forwarded to fsio for atomic-publication boundaries. */ internals: FsIoInternals = {} /** Per-targetKey tail promise: serializes mutating ops so the read→guard→write * window can't interleave, making concurrent writes/edits deterministically @@ -64,7 +76,13 @@ export class LocalFileSystem extends FileSystem { constructor(ctx: Context, config: Config) { super(ctx) - this.config = config as ResolvedConfig + const resolved = config as ResolvedConfig + if (!Number.isSafeInteger(resolved.diffBasisMaxBytes) + || resolved.diffBasisMaxBytes <= 0 + || resolved.diffBasisMaxBytes > MAX_DIFF_BASIS_BYTES) { + throw new Error(`fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${MAX_DIFF_BASIS_BYTES}`) + } + this.config = resolved } /** Run `op` with exclusive access to `targetKey` (FIFO per key). */ @@ -150,9 +168,16 @@ export class LocalFileSystem extends FileSystem { } // No expectation means an unconditional but still atomic write. - // Preserve prior text for contextual diffs; null falls back to a whole-file diff. - // TODO(overwrite-diff-bound): cap this UI-only pre-read for large files. - const before = existing ? await readTextForDiff(target.targetKey, signal) : null + // Capture an optional contextual-diff basis before the write. The bounded + // reader checks the opened file itself, so an external replacement after + // `probe()` cannot turn this best-effort presentation read into an + // unbounded allocation. Either side at/above the configured limit yields + // `before: null`; consumers retain their whole-file fallback. + const diffable = existing !== null + && Buffer.byteLength(content, 'utf8') < this.config.diffBasisMaxBytes + const before = diffable + ? await readTextForDiff(target.targetKey, this.config.diffBasisMaxBytes, signal) + : null await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals) const after = await probe(target.targetKey) return { diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 61e2c0e999..5b66eaa2a0 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -7,6 +7,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { constants as bufferConstants } from 'node:buffer' import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -42,13 +43,39 @@ async function versionOf(target: FsTarget): Promise { return info.version } +async function remountWithDiffLimit(diffBasisMaxBytes: number): Promise { + await fiber.dispose() + fiber = await ctx.plugin(LocalFileSystem, { cwd: dir, diffBasisMaxBytes }) + fs = ctx.fs as LocalFileSystem +} + describe('registration', () => { it('registers LocalFileSystem as ctx.fs with a default cwd', async () => { const bare = new Context() const bareFiber = await bare.plugin(LocalFileSystem) expect((bare.fs as LocalFileSystem).config.cwd).toBe(process.cwd()) + expect((bare.fs as LocalFileSystem).config.diffBasisMaxBytes).toBe(10 * 1024 * 1024) await bareFiber.dispose() }) + + it('rejects non-positive, fractional, unsafe, or unallocatable diff-basis limits', async () => { + const maxDiffBasisBytes = Math.min( + bufferConstants.MAX_LENGTH, + bufferConstants.MAX_STRING_LENGTH, + ) + const valid = new Context() + const validFiber = await valid.plugin(LocalFileSystem, { diffBasisMaxBytes: maxDiffBasisBytes }) + expect((valid.fs as LocalFileSystem).config.diffBasisMaxBytes).toBe(maxDiffBasisBytes) + await validFiber.dispose() + + for (const diffBasisMaxBytes of [0, -1, 1.5, maxDiffBasisBytes + 1, Number.MAX_SAFE_INTEGER + 1]) { + const invalid = new Context() + await expect(invalid.plugin(LocalFileSystem, { diffBasisMaxBytes })).rejects.toThrow( + `fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${maxDiffBasisBytes}`, + ) + await invalid.fiber.dispose() + } + }) }) describe('resolve', () => { @@ -384,6 +411,42 @@ describe('writeText', () => { expect(outcome.after).toBe('now valid') }) + it('an overwrite of a prior file AT the whole-file bound reports before:null (undiffable), still succeeds', async () => { + // The configured bound keeps the fixture small; 8 bytes at a bound of 8 + // pins the exclusive edge without coupling this provider to a read tool. + await remountWithDiffLimit(8) + await writeFile(join(dir, 'big.txt'), '12345678') + const target = await fs.resolve('big.txt') + const outcome = await fs.writeText(target, 'tiny') + expect(outcome.operation).toBe('update') + expect(outcome.before).toBeNull() + expect(outcome.after).toBe('tiny') + }) + + it('an overwrite whose NEW content is at the whole-file bound reports before:null (no huge contextual diff)', async () => { + // The bound gates BOTH sides of the diff pair: a small prior file rewritten + // with at/above-bound content yields no contextual-hunk basis either, since + // a small-to-huge rewrite's hunk is as large as the new content — the + // consumer must fall back to the whole-file diff card, exactly like a + // create of the same size. + await remountWithDiffLimit(8) + await writeFile(join(dir, 'grow.txt'), 'tiny') + const target = await fs.resolve('grow.txt') + const outcome = await fs.writeText(target, '12345678') + expect(outcome.operation).toBe('update') + expect(outcome.before).toBeNull() + expect(outcome.after).toBe('12345678') + }) + + it('an overwrite with BOTH sides below the whole-file bound keeps its contextual before basis', async () => { + await remountWithDiffLimit(8) + await writeFile(join(dir, 'small.txt'), '1234567') + const target = await fs.resolve('small.txt') + const outcome = await fs.writeText(target, 'new') + expect(outcome.before).toBe('1234567') + expect(outcome.after).toBe('new') + }) + it('releases per-target mutation locks after success and failure', async () => { const target = await fs.resolve('a.txt') await fs.writeText(target, 'created', { kind: 'createIfAbsent' }) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 15588e40b9..32232c424c 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -5,7 +5,7 @@ * policy and lives in `dsh-fs-policy`, so it is not tested here. */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -16,6 +16,7 @@ import { probe, probeNoFollow, readForEdit, + readTextForDiff, readWholeText, resolveLocalTarget, restoreLineEndings, @@ -316,6 +317,205 @@ describe('readWholeText', () => { }) }) +describe('readTextForDiff', () => { + it('returns normalized text only when the opened file is strictly below the limit', async () => { + const file = join(dir, 'basis.txt') + await writeFile(file, 'a\r\nb') + expect(await readTextForDiff(file, 5)).toBe('a\nb') + expect(await readTextForDiff(file, 4)).toBeNull() + }) + + it('bounds the actual opened file rather than trusting an earlier path size', async () => { + const file = join(dir, 'replaced.txt') + await writeFile(file, 'tiny') + const earlierSize = (await stat(file)).size + await writeFile(file, '123456789') + expect(earlierSize).toBeLessThan(8) + expect(await readTextForDiff(file, 8)).toBeNull() + }) + + it('returns null when the opened file shrinks after descriptor stat', async () => { + const file = join(dir, 'shrinking.txt') + await writeFile(file, 'abcdef') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters) { + const handle = await actual.open(...args) + return { + close: handle.close.bind(handle), + read: handle.read.bind(handle), + async stat(...statArgs: Parameters) { + const info = await handle.stat(...statArgs) + await writeFile(file, 'abc') + return info + }, + } + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + expect(await isolatedReadTextForDiff(file, 8)).toBeNull() + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) + + it('returns null when the opened file grows after descriptor stat', async () => { + // Pins the one-extra-byte EOF probe: with a buffer of exactly openedSize a + // grown file would read openedSize bytes and pass the consistency check. + const file = join(dir, 'growing.txt') + await writeFile(file, 'abcdef') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters) { + const handle = await actual.open(...args) + return { + close: handle.close.bind(handle), + read: handle.read.bind(handle), + async stat(...statArgs: Parameters) { + const info = await handle.stat(...statArgs) + await writeFile(file, 'abcdef-grown') + return info + }, + } + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + expect(await isolatedReadTextForDiff(file, 32)).toBeNull() + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) + + it('returns null for binary and invalid UTF-8 without blocking the caller write', async () => { + await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69])) + await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69])) + expect(await readTextForDiff(join(dir, 'bin'), 8)).toBeNull() + expect(await readTextForDiff(join(dir, 'bad'), 8)).toBeNull() + }) + + it('honors a pre-aborted signal', async () => { + const file = join(dir, 'basis.txt') + await writeFile(file, 'text') + await expect(readTextForDiff(file, 8, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) + + it.each(['open', 'stat'] as const)('observes cancellation immediately after %s', async (stage) => { + const file = join(dir, 'basis.txt') + await writeFile(file, 'text') + const reached = Promise.withResolvers() + const release = Promise.withResolvers() + let statCalls = 0 + const allocate = vi.spyOn(Buffer, 'allocUnsafe') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters) { + const handle = await actual.open(...args) + if (stage === 'open') { + reached.resolve(undefined) + await release.promise + } + return { + close: handle.close.bind(handle), + read: handle.read.bind(handle), + async stat(...statArgs: Parameters) { + statCalls += 1 + const info = await handle.stat(...statArgs) + if (stage === 'stat') { + reached.resolve(undefined) + await release.promise + } + return info + }, + } + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + const controller = new AbortController() + const pending = isolatedReadTextForDiff(file, 8, controller.signal) + await reached.promise + const allocationCalls = allocate.mock.calls.length + controller.abort() + release.resolve(undefined) + await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' }) + expect(statCalls).toBe(stage === 'open' ? 0 : 1) + expect(allocate).toHaveBeenCalledTimes(allocationCalls) + } finally { + release.resolve(undefined) + allocate.mockRestore() + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) + + it('bounds descriptor reads and observes cancellation before the next chunk', async () => { + const file = join(dir, 'large-basis.txt') + const fileBytes = 200 * 1024 + await writeFile(file, 'x'.repeat(fileBytes)) + const firstRead = Promise.withResolvers() + const releaseFirstRead = Promise.withResolvers() + const readLengths: number[] = [] + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters) { + const handle = await actual.open(...args) + return { + stat: handle.stat.bind(handle), + close: handle.close.bind(handle), + async read(buffer: Buffer, offset: number, length: number, position: number | null) { + readLengths.push(length) + const result = await handle.read(buffer, offset, length, position) + if (readLengths.length === 1) { + firstRead.resolve(undefined) + await releaseFirstRead.promise + } + return result + }, + } + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + const controller = new AbortController() + const pending = isolatedReadTextForDiff(file, fileBytes + 1, controller.signal) + await firstRead.promise + expect(readLengths).toEqual([64 * 1024]) + controller.abort() + releaseFirstRead.resolve(undefined) + await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' }) + expect(readLengths).toHaveLength(1) + } finally { + releaseFirstRead.resolve(undefined) + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) +}) + describe('streamWholeText', () => { it('streams the whole file as decoded text', async () => { const file = join(dir, 'a.txt') diff --git a/packages/fs/fs-sandbox/README.i18n.yaml b/packages/fs/fs-sandbox/README.i18n.yaml index 35a33d135b..4f7771cd3c 100644 --- a/packages/fs/fs-sandbox/README.i18n.yaml +++ b/packages/fs/fs-sandbox/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/fs-sandbox/README.md -README.md: c40fc7999ab85a70702f65a5675208163f5fc351 -README.zh.md: 15db5abbfc5307c0570925026ec435d8dbb51bf2 +README.md: ae1fd746c711a86e308a02e0054ba478e8d913c0 +README.zh.md: e25a3467c06fbd93de9bb75d6b364e8da5a451fe diff --git a/packages/fs/fs-sandbox/README.md b/packages/fs/fs-sandbox/README.md index c40fc7999a..ae1fd746c7 100644 --- a/packages/fs/fs-sandbox/README.md +++ b/packages/fs/fs-sandbox/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) `SandboxedFileSystem` extends [`LocalFileSystem`](../fs-local/README.md) and registers as `ctx.fs`. It inherits every text-storage mechanic verbatim (resolve, stat, read/stream, list, the atomic write, the read-match-write edit critical section) and adds only a per-call MODE fence on `writeText`/`editText`. Reads always pass through — every mode permits reading. +Its plugin config is the local backend config unchanged: `cwd` remains the relative-path resolution default, and `diffBasisMaxBytes` bounds the optional overwrite contextual-diff basis. + Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. The tool layer resolves the calling session's mode and cwd into the SAME per-call policy bash receives, so the two families never confine to different roots. ## The fence diff --git a/packages/fs/fs-sandbox/README.zh.md b/packages/fs/fs-sandbox/README.zh.md index 15db5abbfc..e25a3467c0 100644 --- a/packages/fs/fs-sandbox/README.zh.md +++ b/packages/fs/fs-sandbox/README.zh.md @@ -4,6 +4,8 @@ `SandboxedFileSystem` 扩展 [`LocalFileSystem`](../fs-local/README.md) 并注册为 `ctx.fs`。它逐字继承全部文本存储机制(解析、stat、读取/流式读取、列出、原子写入、按读取、匹配、写入顺序执行的编辑临界区),只为 `writeText`/`editText` 增加按调用的模式围栏。读取始终直接通过:所有模式都允许读取。 +它原样复用本地后端配置:`cwd` 仍是相对路径的解析默认值,`diffBasisMaxBytes` 则限制可选的覆写上下文 diff 基础。 + 只需加载它来替代 `dsh-fs-local`,并同时加载 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md),即可完成替换;面向模型的工具(`dsh-tool-fs`)无需改动。工具层把调用会话的模式和 cwd 解析为与 bash 相同的按调用策略,因此两个能力族绝不会约束到不同根目录。 ## 围栏 diff --git a/packages/fs/fs-sandbox/src/index.ts b/packages/fs/fs-sandbox/src/index.ts index 5e0121c89a..51248149ff 100644 --- a/packages/fs/fs-sandbox/src/index.ts +++ b/packages/fs/fs-sandbox/src/index.ts @@ -41,10 +41,10 @@ import type {} from '@deepseek-ai/dsh-sandbox-policy' import { isPathUnder } from './containment.ts' /** - * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve - * base for relative paths). The sandbox default (mode + `workspace-write` - * fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling - * session for every enforcing capability. + * Plugin config: the local backend's knobs verbatim (`cwd` resolution default + * and `diffBasisMaxBytes` overwrite-presentation bound). The sandbox default + * (mode + `workspace-write` fallback root) is NOT here — `ctx.sandboxPolicy` + * resolves each calling session for every enforcing capability. */ export type Config = LocalConfig diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index f5753f09bd..a44f4c6bc7 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -123,10 +123,11 @@ export interface FsWriteOutcome { version: FsVersion /** * The file's content BEFORE the write, or `null` when the file did not exist - * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text - * (the diff basis), never a diff — a consumer computes the result-time - * contextual diff from `before`/`after` when `before` is present, else falls - * back to a whole-file diff. + * (a create) or the backend declined a contextual basis (for example, a + * binary/non-UTF-8 prior file or either overwrite side reaching its exclusive limit). + * LF-normalized storage text (the diff basis), never a diff — a consumer + * computes the result-time contextual diff from `before`/`after` when + * `before` is present, else falls back to a whole-file diff. */ before: string | null /** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */ From da05e5f058ff897bbf7e302ede2be97cd6a15605 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 02:07:54 +0800 Subject: [PATCH 050/229] test(gui): complete merged image input checks --- docs/module-graph.md | 57 ++++++++++++------- .../ui-conversation/tests/input-bar.spec.tsx | 8 +-- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index d963273363..01c601fd6b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -151,6 +151,10 @@ flowchart TD pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] end + subgraph group_attachment["packages/attachment"] + pkg_attachment["attachment"] + pkg_attachment_local["attachment-local"] + end subgraph group_bundle["packages/bundle"] pkg_base["base"] pkg_headless["headless"] @@ -320,9 +324,8 @@ flowchart TD pkg_type_meta --> pkg_invariants pkg_typert_generator --> pkg_invariants pkg_typert_registry --> pkg_invariants - pkg_llm --> pkg_brand - pkg_llm --> pkg_invariants - pkg_llm --> pkg_timeout + pkg_attachment --> pkg_brand + pkg_attachment --> pkg_invariants pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_hmr --> pkg_client_modules @@ -351,12 +354,32 @@ flowchart TD pkg_subprocess_local --> pkg_timeout pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry + pkg_llm --> pkg_attachment + pkg_llm --> pkg_brand + pkg_llm --> pkg_invariants + pkg_llm --> pkg_timeout + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry + pkg_attachment_local --> pkg_attachment + pkg_attachment_local --> pkg_invariants + pkg_attachment_local --> pkg_paths + pkg_credentials_local --> pkg_atomic_write + pkg_credentials_local --> pkg_credentials + pkg_credentials_local --> pkg_environment + pkg_credentials_local --> pkg_invariants + pkg_credentials_local --> pkg_paths + pkg_settings_local --> pkg_atomic_write + pkg_settings_local --> pkg_invariants + pkg_settings_local --> pkg_paths + pkg_settings_local --> pkg_settings pkg_llm_deepseek --> pkg_credentials pkg_llm_deepseek --> pkg_environment pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_settings pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_credentials pkg_llm_pi_ai --> pkg_environment pkg_llm_pi_ai --> pkg_invariants @@ -373,23 +396,11 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry - pkg_credentials_local --> pkg_atomic_write - pkg_credentials_local --> pkg_credentials - pkg_credentials_local --> pkg_environment - pkg_credentials_local --> pkg_invariants - pkg_credentials_local --> pkg_paths pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm - pkg_settings_local --> pkg_atomic_write - pkg_settings_local --> pkg_invariants - pkg_settings_local --> pkg_paths - pkg_settings_local --> pkg_settings pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope @@ -1044,6 +1055,8 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -1188,7 +1201,7 @@ flowchart TD | [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | @@ -1201,16 +1214,18 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`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) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `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) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | -| [`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) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`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) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1335,7 +1350,7 @@ flowchart TD | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 8aa3c74436..473585f282 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -501,7 +501,7 @@ describe('running and lock semantics (queue cut 1)', () => { } // Pasted text lands below the fold: scroll down by exactly the overshoot. caretAt(500) - fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } }) + fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'pasted' } }) await settle() expect(scroll.scrollTop).toBe(88) // 524 - 436 // Measured on the mirror's own text, at the index the paste left the caret @@ -510,12 +510,12 @@ describe('running and lock semantics (queue cut 1)', () => { expect(measured!.offset).toBe('pasted'.length) // A caret already inside the box does not move it. caretAt(200) - fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } }) + fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'more' } }) await settle() expect(scroll.scrollTop).toBe(88) // Above the fold (a cut can leave it there): scroll back up. caretAt(60) - fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } }) + fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'again' } }) await settle() expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60) // A caret straight after a newline has nothing on its line to measure, so @@ -523,7 +523,7 @@ describe('running and lock semantics (queue cut 1)', () => { // chromium reports no client rects at all for the collapsed position. mirror.style.lineHeight = '24px' caretAt(500) - fireEvent.paste(textarea, { clipboardData: { getData: () => 'block\n' } }) + fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'block\n' } }) await settle() // The four pastes accumulate at the draft's head, so the caret is at the // end of what they inserted — and the measured index is the newline before it. From ede9020e18f64ffbbb0862703cf00a177da39707 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 02:20:09 +0800 Subject: [PATCH 051/229] test(gui): stabilize merged snapshot gates --- apps/web/tests/subagent-conversation.e2e.ts | 2 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../src/client/skeleton/InputBar.module.css | 70 ------------------- 3 files changed, 2 insertions(+), 72 deletions(-) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 0049cb2791..99bcac0525 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -461,7 +461,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = ).toBe(3) expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0) const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) - expect(await hierarchy.getByRole('button').count()).toBe(1) + await expect.poll(() => hierarchy.getByRole('button').count()).toBe(1) await compareOrRefreshGolden( FORK_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 12a88365a6..0a062820e0 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 63c9392cb7..275c210850 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -121,25 +121,6 @@ pointer-events: none; } -.dragActive { - border-color: var(--dsw-alias-state-business-primary); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2); -} - -.dropHint { - position: absolute; - z-index: 2; - inset: 4px; - display: grid; - place-items: center; - border-radius: 16px; - background: color-mix(in srgb, var(--dsw-specific-input-major) 88%, var(--dsw-alias-state-business-primary)); - color: var(--dsw-alias-state-business-primary); - font-size: 14px; - font-weight: 600; - pointer-events: none; -} - .accessory { display: flex; align-items: center; @@ -198,57 +179,6 @@ cursor: pointer; } -.attachments { - display: flex; - gap: 8px; - min-width: 0; - padding: 12px 12px 0; - overflow-x: auto; - overflow-y: hidden; -} - -.attachment { - position: relative; - flex: 0 0 72px; - width: 72px; - height: 72px; -} - -.thumbnail { - width: 72px; - height: 72px; - padding: 0; - overflow: hidden; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 12px; - background: var(--dsw-alias-interactive-bg-hover); - cursor: zoom-in; -} - -.thumbnail img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.remove { - position: absolute; - top: -6px; - right: -6px; - display: grid; - place-items: center; - width: 22px; - height: 22px; - padding: 0; - border: 1px solid var(--dsw-specific-input-major); - border-radius: 999px; - background: var(--dsw-alias-label-primary); - color: var(--dsw-specific-input-major); - font-size: 16px; - line-height: 1; - cursor: pointer; -} - /* Floating overlay anchor (menu / popupSelect shell): entries position themselves against the card (bottom: 100% + gap); closed entries render null. */ .overlayAnchor { From d7705e2dcd8e8f8ca44e2188d9284014b478cdab Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:56:08 -0700 Subject: [PATCH 052/229] test(fs-local): pin multibyte byte-length gating --- packages/fs/fs-local/tests/filesystem.spec.ts | 12 ++++++++++++ packages/fs/fs-local/tests/fsio.spec.ts | 2 -- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 5b66eaa2a0..4f52e232d1 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -438,6 +438,18 @@ describe('writeText', () => { expect(outcome.after).toBe('12345678') }) + it('gates the NEW content by UTF-8 byte length, not character count', async () => { + // Three CJK characters are 9 UTF-8 bytes: below an 8-byte bound by + // characters but at/above it by bytes, so the basis must be declined. + await remountWithDiffLimit(8) + await writeFile(join(dir, 'cjk.txt'), 'tiny') + const target = await fs.resolve('cjk.txt') + const outcome = await fs.writeText(target, '你好吗') + expect(outcome.operation).toBe('update') + expect(outcome.before).toBeNull() + expect(outcome.after).toBe('你好吗') + }) + it('an overwrite with BOTH sides below the whole-file bound keeps its contextual before basis', async () => { await remountWithDiffLimit(8) await writeFile(join(dir, 'small.txt'), '1234567') diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 32232c424c..d800997d0f 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -367,8 +367,6 @@ describe('readTextForDiff', () => { }) it('returns null when the opened file grows after descriptor stat', async () => { - // Pins the one-extra-byte EOF probe: with a buffer of exactly openedSize a - // grown file would read openedSize bytes and pass the consistency check. const file = join(dir, 'growing.txt') await writeFile(file, 'abcdef') vi.resetModules() From 9a299f9827e6d34158ce3e50d4ab5062c886f9a7 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:49:42 -0700 Subject: [PATCH 053/229] fix(fs-local): degrade basis I/O failures to null Descriptor-phase errnos in readTextForDiff fold to before: null so a file deleted or made unreadable after the caller's preflight cannot fail the committed write; cancellation and non-errno faults still propagate. Drops the now-covered isFile v8 ignore, extends llm-replay with catalog capability parity (defaultMaxTokens/reasoningEfforts), and records the fs-write-overwrite-bounded keyless snapshot pinning the over-limit whole-file fallback through the real acp-agent composition. --- ...-30-bounded-overwrite-diff-basis.i18n.yaml | 4 +- ...2026-07-30-bounded-overwrite-diff-basis.md | 2 +- ...6-07-30-bounded-overwrite-diff-basis.zh.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 16 ++++ .../tests/fs-diff-bound.cordis.snapshot.yml | 46 ++++++++++ .../acp-agent/tests/fs-diff-bound.cordis.yml | 29 +++++++ .../fs-write-overwrite-bounded/input.json | 7 ++ .../fs-write-overwrite-bounded/session.jsonl | 42 +++++++++ .../stdout.expected.jsonl | 4 + .../workspace/data.txt | 1 + packages/fs/fs-local/src/fsio.ts | 86 +++++++++++-------- packages/fs/fs-local/tests/fsio.spec.ts | 58 +++++++++++++ packages/support/llm-replay/src/index.ts | 27 +++++- 13 files changed, 281 insertions(+), 43 deletions(-) create mode 100644 examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml create mode 100644 examples/acp-agent/tests/fs-diff-bound.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml index f7361eda32..da4e330119 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md -2026-07-30-bounded-overwrite-diff-basis.md: 353b538a12b8cf48dfa3a561c62d6d8ab9a8bfcf -2026-07-30-bounded-overwrite-diff-basis.zh.md: e2b473a21d16dc47b5b8bf781b8ddffdf443955b +2026-07-30-bounded-overwrite-diff-basis.md: 7a09934bd1798059de43a092f338d37aa9ccbd9a +2026-07-30-bounded-overwrite-diff-basis.zh.md: 1d6bdd1068d119aae859132d9f8216ca29d0dc11 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md index 353b538a12..7a09934bd1 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md @@ -10,7 +10,7 @@ English | [中文](2026-07-30-bounded-overwrite-diff-basis.zh.md) ## Decision -`LocalFileSystem.Config.diffBasisMaxBytes` is a positive safe-integer deployment setting no greater than the runtime's Buffer-allocation and string-decoding limits, with a 10 MiB default. An overwrite supplies `before` only when the UTF-8 replacement is strictly below that limit and the prior file opened for the basis also ends below it. The prior read opens a descriptor, checks that descriptor, and reads at most the configured byte count in cancellation-aware chunks; reaching the boundary returns `null`. A size change after descriptor stat also returns `null`, even if the final size remains below the limit, because a partial prefix would be an incorrect diff basis. Binary or invalid UTF-8 prior content likewise returns `null`. These outcomes do not block the atomic write. +`LocalFileSystem.Config.diffBasisMaxBytes` is a positive safe-integer deployment setting no greater than the runtime's Buffer-allocation and string-decoding limits, with a 10 MiB default. An overwrite supplies `before` only when the UTF-8 replacement is strictly below that limit and the prior file opened for the basis also ends below it. The prior read opens a descriptor, checks that descriptor, and reads at most the configured byte count in cancellation-aware chunks; reaching the boundary returns `null`. A size change after descriptor stat also returns `null`, even if the final size remains below the limit, because a partial prefix would be an incorrect diff basis. Binary or invalid UTF-8 prior content likewise returns `null`, as does any descriptor-phase errno — a prior file deleted or made unreadable between the caller's preflight and the basis open cannot fail a write the caller already committed to; only cancellation and non-errno faults propagate. These outcomes do not block the atomic write. The local provider owns this decision because `before` is its optional, best-effort basis: it can avoid acquiring prior content that the configured pair limit has already made ineligible. `tool-fs` continues to own diff computation, retention, and presentation. The setting is independent of `tool-fs.readStreamMinSize`; read routing and overwrite presentation are different policies and need not share a value. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md index e2b473a21d..1d6bdd1068 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -`LocalFileSystem.Config.diffBasisMaxBytes` 是一个不超过运行时 Buffer 分配和字符串解码上限的正安全整数部署配置,默认 10 MiB。只有当 UTF-8 替换内容严格低于该上限,且为生成基础而打开的旧文件最终也低于该上限时,覆写才提供 `before`。旧文件读取会打开文件描述符、检查该描述符,并按可响应取消的分块最多读取配置的字节数;一旦到达边界便返回 `null`。描述符 stat 后发生大小变化时同样返回 `null`,即使最终大小仍低于上限,因为部分前缀会成为错误的 diff 基础。旧内容为二进制或无效 UTF-8 时也返回 `null`。这些结果都不会阻止原子写入。 +`LocalFileSystem.Config.diffBasisMaxBytes` 是一个不超过运行时 Buffer 分配和字符串解码上限的正安全整数部署配置,默认 10 MiB。只有当 UTF-8 替换内容严格低于该上限,且为生成基础而打开的旧文件最终也低于该上限时,覆写才提供 `before`。旧文件读取会打开文件描述符、检查该描述符,并按可响应取消的分块最多读取配置的字节数;一旦到达边界便返回 `null`。描述符 stat 后发生大小变化时同样返回 `null`,即使最终大小仍低于上限,因为部分前缀会成为错误的 diff 基础。旧内容为二进制或无效 UTF-8 时也返回 `null`;描述符阶段的任何 errno 同样如此——旧文件在调用方预检之后、基础读取打开之前被删除或变得不可读,不能让调用方已经提交的写入失败;只有取消和非 errno 故障会继续向上传播。这些结果都不会阻止原子写入。 本地提供方拥有该决策,因为 `before` 是它提供的可选、尽力而为的基础:当配置的成对上限已使替换内容不合格时,它可以避免获取旧内容。`tool-fs` 继续拥有 diff 计算、保留与展示。该配置独立于 `tool-fs.readStreamMinSize`;读取路由与覆写展示是不同策略,无需共享数值。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index c09dd7d2ed..79c63fb642 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -52,6 +52,7 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) +const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -253,6 +254,21 @@ const SCENARIOS: Scenario[] = [ { name: 'fs-write', hasModelTurn: true, recorded: true }, { name: 'fs-edit', hasModelTurn: true, recorded: true }, { name: 'fs-write-overwrite', hasModelTurn: true, recorded: true }, + // An overwrite whose replacement is at/above the configured diff-basis bound: + // the persisted result meta carries no contextual hunks and presentation + // falls back to the whole-file diff. The overlay leaves the prompt and tool + // sequence identical to text-turn, but the freshly recorded header carries + // the current adapter capability fields, so the scenario pins its own class. + { + name: 'fs-write-overwrite-bounded', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'fs-diff-bound', + systemPromptSource: 'text-turn', + toolSchemasSource: 'text-turn', + configPath: FS_DIFF_BOUND_CONFIG, + }, { name: 'fs-read-window', hasModelTurn: true, recorded: true }, { name: 'fs-policy-reject', hasModelTurn: true, recorded: true }, { name: 'multi-turn', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml b/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml new file mode 100644 index 0000000000..a216a34cfd --- /dev/null +++ b/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml @@ -0,0 +1,46 @@ +# Keyless replay counterpart to fs-diff-bound.cordis.yml. Replay patches apply +# directly against the live cordis.yml because include patches cannot target +# entries behind a nested include; the acp-agent restatement keeps the recorded +# deepseek-v4-flash model and raw JSONL persistence for the harness's harvest. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + config: + cwd: !!js process.cwd() + diffBasisMaxBytes: 64 + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + # Capability parity with the live adapter so replay + # reconstructs the freshly recorded request header. + - id: deepseek-v4-flash + contextWindow: 1000000 + defaultMaxTokens: 256000 + reasoningEfforts: ['off', 'high', 'max'] + defaultReasoningEffort: max + - id: deepseek-v4-pro diff --git a/examples/acp-agent/tests/fs-diff-bound.cordis.yml b/examples/acp-agent/tests/fs-diff-bound.cordis.yml new file mode 100644 index 0000000000..9be25cdb25 --- /dev/null +++ b/examples/acp-agent/tests/fs-diff-bound.cordis.yml @@ -0,0 +1,29 @@ +# Live counterpart for the bounded-overwrite-diff snapshot: the base stack with +# the fs backend's overwrite diff-basis limit shrunk so a modest replacement +# crosses the exclusive bound and the write result falls back to a whole-file +# diff. A config patch replaces the row's whole config, so `cwd` is restated +# verbatim, and the acp-agent restatement re-pins `deepseek-v4-flash` to match +# the recorded corpus and its pinned request headers. +- id: base + name: '@cordisjs/plugin-include' + 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: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + config: + cwd: !!js process.cwd() + diffBasisMaxBytes: 64 diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json new file mode 100644 index 0000000000..480c37827a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly this single line: The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound. Then reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl new file mode 100644 index 0000000000..688bf9cb70 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl @@ -0,0 +1,42 @@ +{"type":"session","version":0,"id":"14b14f51-2428-43a0-bcc5-5f392d4faa19","createdAt":1786204699215,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786204699218,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly this single line: The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"41d72cfe-0e37-474f-83dc-2b15bacf9c0d"}]}} +{"type":"turn/start","seq":1,"time":1786204699219,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786204699220,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786204699259,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786204699259,"data":{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly this single line: The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"41d72cfe-0e37-474f-83dc-2b15bacf9c0d"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786204699260,"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":"e374fb32-1cad-4e2d-9cd3-66ac8fcf9588"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786204699261,"data":{"title":"First use the read tool","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786204699262,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786204699262,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}} +{"type":"assistant/chunk","seq":9,"time":1786204701601,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1786204701602,"data":{"turn":1,"step":1,"index":0,"dt":[60,22,2,1,0,1,0,19,2,1,1,17,21,2,0,20,2,1,21,0,0,0,1,21,2],"texts":["The"," user"," wants"," me"," to"," read"," data",".txt"," first",","," then"," write"," to"," replace"," its"," contents"," with"," the"," exact"," line",","," then"," reply"," D","ONE","."]}} +{"type":"assistant/chunk","seq":36,"time":1786204701863,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":37,"time0":1786204701864,"data":{"turn":1,"step":1,"index":1,"dt":[21,2,0,21,2,1,0,26,1,0,17],"id":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","args":["","{","\"","file","_path","\"",": ","\"","data",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":49,"time":1786204701981,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read data.txt first, then write to replace its contents with the exact line, then reply DONE."}}}} +{"type":"assistant/chunk","seq":50,"time":1786204701982,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}}} +{"type":"assistant/chunk","seq":51,"time":1786204701982,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5803,"outputTokens":71,"cacheReadTokens":0,"reasoningTokens":26}}}} +{"type":"assistant/chunk","seq":52,"time":1786204701982,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":53,"time":1786204701988,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read data.txt first, then write to replace its contents with the exact line, then reply DONE."},{"type":"tool-call","id":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9060e190-9971-4838-81bf-48c3e3888609"},"usage":{"inputTokens":5803,"outputTokens":71,"cacheReadTokens":0,"reasoningTokens":26}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],"surfaceOp":"append"} +{"type":"tool/call","seq":54,"time":1786204701990,"data":{"turn":1,"step":1,"callId":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} +{"type":"tool/result","seq":55,"time":1786204702006,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Jxz49JNt6i4oaDnzes2I0794"},"content":[{"type":"tool-result","toolCallId":"call_00_Jxz49JNt6i4oaDnzes2I0794","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"1406fd7d-f181-41d0-b0db-ef196010f620"},"meta":{"path":"{{cwd}}/data.txt","offset":1,"lines":[{"number":1,"text":"original contents"}],"totalLines":1}},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"step/end","seq":56,"time":1786204702006,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":57,"time":1786204702016,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":58,"time":1786204703539,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":59,"time0":1786204703539,"data":{"turn":1,"step":2,"index":0,"dt":[0,1,0,0,0,1,0,0,1,0,12,2,0,0,0,22,35,1,0,0,0,0,0,1,0,8,2,0,0,71,1,0,0,1,0],"id":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","args":["","{","\"","file","_path","\"",": ","\"","data",".txt","\"",", ","\"","content","\"",": ","\"","The"," replacement"," line"," is"," deliberately"," longer"," than"," the"," configured"," sixty","-four"," byte"," diff","-b","asis"," bound",".","\"","}"]}} +{"type":"assistant/chunk","seq":95,"time":1786204703720,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}}}} +{"type":"assistant/chunk","seq":96,"time":1786204703720,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":202,"outputTokens":76,"cacheReadTokens":5760,"reasoningTokens":0}}}} +{"type":"assistant/chunk","seq":97,"time":1786204703720,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":98,"time":1786204703722,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"46d3792a-eded-45e7-8151-00ca0584f10b"},"usage":{"inputTokens":202,"outputTokens":76,"cacheReadTokens":5760,"reasoningTokens":0}},"sourceEventSeqs":[58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97],"surfaceOp":"append"} +{"type":"tool/call","seq":99,"time":1786204703722,"data":{"turn":1,"step":2,"callId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}} +{"type":"tool/result","seq":100,"time":1786204703740,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"98c41fc1-6ce6-445f-94f7-32aa7e1c6ea7"},"meta":{"diffs":[]}},"sourceEventSeqs":[99],"surfaceOp":"append"} +{"type":"step/end","seq":101,"time":1786204703740,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":102,"time":1786204703749,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":103,"time":1786204705029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":104,"time":1786204705029,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":105,"time":1786204705053,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":106,"time":1786204705055,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":107,"time":1786204705055,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":3,"cacheReadTokens":6016,"reasoningTokens":0}}}} +{"type":"assistant/chunk","seq":108,"time":1786204705056,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":109,"time":1786204705057,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ddf50859-b0b9-404d-a71c-a1f11ff53341"},"usage":{"inputTokens":100,"outputTokens":3,"cacheReadTokens":6016,"reasoningTokens":0}},"sourceEventSeqs":[103,104,105,106,107,108],"surfaceOp":"append"} +{"type":"step/end","seq":110,"time":1786204705057,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":111,"time":1786204705058,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/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/fs-write-overwrite-bounded/workspace/data.txt b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt new file mode 100644 index 0000000000..3359a4b8d9 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt @@ -0,0 +1 @@ +original contents \ No newline at end of file diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index b47d6f2bcd..cba29e873c 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -565,15 +565,16 @@ export async function readForEdit( } /** - * Best-effort overwrite diff basis. Binary, invalid UTF-8, or a file at/above the byte limit - * returns `null` so the write still succeeds and presentation falls back to a whole-file diff. - * The bound is enforced on the opened descriptor rather than a prior path stat, so concurrent - * external replacement or size changes cannot make this helper buffer more than `maxBytes`. - * @param absolutePath - the file to read (typically a target key); it must exist. + * Best-effort overwrite diff basis. Binary, invalid UTF-8, a file at/above the byte limit, + * or a file deleted/made unreadable after the caller's preflight returns `null` so the write + * still succeeds and presentation falls back to a whole-file diff. The bound is enforced on + * the opened descriptor rather than a prior path stat, so concurrent external replacement or + * size changes cannot make this helper buffer more than `maxBytes`. + * @param absolutePath - the file to read (typically a target key). * @param maxBytes - exclusive upper bound for bytes held as the contextual-diff basis. - * @param signal - aborts the read (`FS_ABORTED`). + * @param signal - aborts the read (`FS_ABORTED`); cancellation propagates, unlike I/O failure. * @returns the LF-normalized text, or null for a non-regular, at/above-limit, binary, non-UTF-8, - * or descriptor-size-changed file. + * descriptor-size-changed, or unreadable file. */ export async function readTextForDiff( absolutePath: string, @@ -581,41 +582,50 @@ export async function readTextForDiff( signal?: AbortSignal, ): Promise { throwIfAborted(signal, 'read') - const handle = await open(absolutePath, 'r') - let buffer: Buffer - let total = 0 - let openedSize = 0 try { - throwIfAborted(signal, 'read') - const info = await handle.stat() - throwIfAborted(signal, 'read') - /* v8 ignore next -- requires a post-preflight replacement with a non-file; - * direct coverage is not portable to Windows. */ - if (!info.isFile()) return null - if (info.size >= maxBytes) return null - openedSize = info.size - // One extra byte detects growth after stat without retaining per-read backing buffers. - buffer = Buffer.allocUnsafe(openedSize + 1) - while (total < buffer.length) { + const handle = await open(absolutePath, 'r') + let buffer: Buffer + let total = 0 + let openedSize = 0 + try { throwIfAborted(signal, 'read') - const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES) - const { bytesRead } = await handle.read(buffer, total, length, null) - if (bytesRead === 0) break - total += bytesRead + const info = await handle.stat() + throwIfAborted(signal, 'read') + if (!info.isFile()) return null + if (info.size >= maxBytes) return null + openedSize = info.size + // One extra byte detects growth after stat without retaining per-read backing buffers. + buffer = Buffer.allocUnsafe(openedSize + 1) + while (total < buffer.length) { + throwIfAborted(signal, 'read') + const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES) + const { bytesRead } = await handle.read(buffer, total, length, null) + if (bytesRead === 0) break + total += bytesRead + } + } finally { + await handle.close() + } + throwIfAborted(signal, 'read') + if (total !== openedSize) return null + const basis = buffer.subarray(0, total) + if (basis.includes(0)) return null + try { + return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(basis)) + } catch (error: unknown) { + /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; + * any other throw is an unreachable runtime fault. */ + if (!(error instanceof TypeError)) throw error + return null } - } finally { - await handle.close() - } - throwIfAborted(signal, 'read') - if (total !== openedSize) return null - const basis = buffer.subarray(0, total) - if (basis.includes(0)) return null - try { - return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(basis)) } catch (error: unknown) { - /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */ - if (!(error instanceof TypeError)) throw error - return null + // Cancellation is the caller's intent and still propagates. + if (error instanceof FsError) throw error + // A descriptor-phase errno — deleted or made unreadable after the caller's + // preflight, or a faulted read — costs only the optional basis: a committed + // write must not fail for a presentation-only pre-read. + if (error instanceof Error && 'code' in error) return null + throw error } } diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index d800997d0f..a30c171fd9 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -398,6 +398,64 @@ describe('readTextForDiff', () => { } }) + it('returns null when the file vanishes before the basis open (deletion race)', async () => { + expect(await readTextForDiff(join(dir, 'deleted-after-preflight.txt'), 32)).toBeNull() + }) + + it('returns null when the opened descriptor is no longer a regular file', async () => { + const file = join(dir, 'swapped.txt') + await writeFile(file, 'abcdef') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters) { + const handle = await actual.open(...args) + return { + close: handle.close.bind(handle), + read: handle.read.bind(handle), + async stat(...statArgs: Parameters) { + const info = await handle.stat(...statArgs) + return Object.assign(info, { isFile: () => false }) + }, + } + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + expect(await isolatedReadTextForDiff(file, 32)).toBeNull() + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) + + it('propagates a non-errno fault instead of masking it as a null basis', async () => { + const file = join(dir, 'faulted.txt') + await writeFile(file, 'abcdef') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open() { + throw new TypeError('forged programming fault') + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + await expect(isolatedReadTextForDiff(file, 32)).rejects.toThrow('forged programming fault') + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) + it('returns null for binary and invalid UTF-8 without blocking the caller write', async () => { await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69])) await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69])) diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 9af72bbbec..de43d19e96 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -24,7 +24,7 @@ import type { StreamChunk, TokenUsage, } from '@deepseek-ai/dsh-llm' -import { LlmAdapter, LlmError, assertNever, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import { LlmAdapter, LlmError, ReasoningEffortId, assertNever, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' /** * One recorded model call. `throw` may replay prefix chunks before failing; @@ -51,6 +51,18 @@ export interface ReplayModelConfig { description?: string /** Optional positive integer context capacity published by the replay adapter. */ contextWindow?: number + /** + * Optional per-request output cap the replay route materializes when callers + * omit one, so replay reconstructs the request header a live catalog produced. + */ + defaultMaxTokens?: number + /** Optional reasoning-effort ids the replay route accepts, in display order. */ + reasoningEfforts?: string[] + /** + * Optional effort materialized when callers omit one; must appear in + * {@link reasoningEfforts} or call resolution rejects the route. + */ + defaultReasoningEffort?: string } /** One provider route exposed by the replay adapter. */ @@ -585,6 +597,19 @@ class ReplayAdapter extends LlmAdapter { ...configuredModel?.contextWindow === undefined ? {} : { context: { contextWindow: configuredModel.contextWindow } }, + ...configuredModel?.defaultMaxTokens === undefined + ? {} + : { defaultMaxTokens: configuredModel.defaultMaxTokens }, + ...configuredModel?.reasoningEfforts === undefined + ? {} + : { + reasoning: { + efforts: configuredModel.reasoningEfforts.map(id => ({ id: ReasoningEffortId(id), name: id })), + ...configuredModel.defaultReasoningEffort === undefined + ? {} + : { defaultEffort: ReasoningEffortId(configuredModel.defaultReasoningEffort) }, + }, + }, }) } From 6f54fd203d55e3a7cda5245d7977df854c3c2e7a Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:23:32 -0700 Subject: [PATCH 054/229] test(llm-replay): cover catalog capability parity Also re-generates the config catalog after the docs-restructure merge. --- docs/config-catalog.md | 16 +++++++++++++-- .../llm-replay/tests/llm-replay.spec.ts | 20 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b9ce45cd3c..d370fc945f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -444,7 +444,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` @@ -907,12 +907,24 @@ export interface ReplayModelConfig { description?: string /** Optional positive integer context capacity published by the replay adapter. */ contextWindow?: number + /** + * Optional per-request output cap the replay route materializes when callers + * omit one, so replay reconstructs the request header a live catalog produced. + */ + defaultMaxTokens?: number + /** Optional reasoning-effort ids the replay route accepts, in display order. */ + reasoningEfforts?: string[] + /** + * Optional effort materialized when callers omit one; must appear in + * {@link reasoningEfforts} or call resolution rejects the route. + */ + defaultReasoningEffort?: string } ``` Depends on: [`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:769`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 4be9e01c9d..8a30a23c5d 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -578,8 +578,14 @@ describe('installLlmReplay (through the real LlmService)', () => { backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, }, models: [ - { id: 'flash', contextWindow: 128_000 }, - { id: 'pro', name: 'Pro', description: 'Larger model' }, + { + id: 'flash', + contextWindow: 128_000, + defaultMaxTokens: 64_000, + reasoningEfforts: ['off', 'max'], + defaultReasoningEffort: 'max', + }, + { id: 'pro', name: 'Pro', description: 'Larger model', reasoningEfforts: ['high'] }, ], }, { id: 'empty' }, @@ -597,8 +603,18 @@ describe('installLlmReplay (through the real LlmService)', () => { await expect(ctx.llm.listModels('empty')).resolves.toEqual([]) await expect(ctx.llm.resolveModelInfo('deepseek', 'flash')).resolves.toMatchObject({ context: { contextWindow: 128_000 }, + defaultMaxTokens: 64_000, + reasoning: { + efforts: [{ id: 'off', name: 'off' }, { id: 'max', name: 'max' }], + defaultEffort: 'max', + }, }) await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('context') + // Efforts without a configured default preserve the provider's own default. + await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.toMatchObject({ + reasoning: { efforts: [{ id: 'high', name: 'high' }] }, + }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('defaultMaxTokens') await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted')).resolves.not.toHaveProperty('context') await expect(ctx.llm.resolveModelInfo('empty', 'unlisted')).resolves.not.toHaveProperty('context') expect(ctx.llm.providerRetryPolicy('deepseek')).toMatchObject({ From b77bbb152628ed3335f23a30ac91944468d6dfdf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 9 Aug 2026 23:38:37 +0800 Subject: [PATCH 055/229] docs(packages): keep package index within budget --- packages/README.i18n.yaml | 4 ++-- packages/README.md | 6 +++--- packages/README.zh.md | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 6c17ddfe4d..6f0a6c6d4a 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: df071b80abff37e0892f0bec444869e33af78de6 -README.zh.md: f3fa2946218c4fe541c115c5cdfb5bf2e6cd4985 +README.md: 5438ff19e1ac6e5c75667b070f0bfa26efa38dd5 +README.zh.md: 83f1424ce07b0f2652392e6e51fbd26a8662a95d diff --git a/packages/README.md b/packages/README.md index df071b80ab..5438ff19e1 100644 --- a/packages/README.md +++ b/packages/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions). +npm scope: `@deepseek-ai/dsh-*`; Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Rules: [package](AGENTS.md), [root](../AGENTS.md#conventions). ## Hierarchy @@ -31,13 +31,13 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface | | [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable surface | | [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | -| [`attachment/`](attachment/README.md) | Durable attachment identity, validation, and local content-addressed storage | Product — stable surface | +| [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage | Product — stable surface | | [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface | | [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface | | [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface | | [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface | -| [`self-modification/`](self-modification/README.md) | The agent modifies its own runtime: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) and restricted repository Plugin loading | Product — stable surface | +| [`self-modification/`](self-modification/README.md) | Agent runtime self-modification: live plugin/service inspection, model-written plugin mount/unmount ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)), restricted repository Plugin loading | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index f3fa294621..83f1424ce0 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -所有包都使用 `@deepseek-ai/dsh-*` scope。Cordis `Service` 子类和函数插件的贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。 +npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。 ## 层级结构 @@ -31,13 +31,13 @@ | [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定接口 | | [`workflow/`](workflow/README.md) | 工作流 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定接口 | | [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定接口 | -| [`attachment/`](attachment/README.md) | 持久附件标识、校验与本地内容寻址存储 | 产品:稳定接口 | +| [`attachment/`](attachment/README.md) | 持久附件标识、校验、本地内容寻址存储 | 产品:稳定接口 | | [`spill/`](spill/README.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | 产品:稳定接口 | | [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定接口 | | [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定接口 | | [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 | | [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 | -| [`self-modification/`](self-modification/README.md) | agent 修改自身运行时:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)),以及受限仓库插件加载 | 产品:稳定接口 | +| [`self-modification/`](self-modification/README.md) | agent 运行时自修改:实时插件/服务检查、模型所写插件挂载/卸载([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md))、受限仓库插件加载 | 产品:稳定接口 | | [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定接口 | | [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、日志支持的标题、会话上报 | 产品:稳定接口 | | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定接口 | From b5934b6f7ce7793c149c2d35f06d31a47e2d2e66 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 11:04:44 +0800 Subject: [PATCH 056/229] cleanup(web): remove the steering interjection caption Steering bubbles render as plain user bubbles; a mid-turn steer is recognizable by its position in the flow. The runtime SteeringMessageNode projection and pending-steering lifecycle are unchanged. Partially supersedes the 2026-08-04 context-source and steer marks note; the new simplification note owns the removal rationale. --- ...b-context-source-and-steer-marks.i18n.yaml | 4 +-- ...8-04-web-context-source-and-steer-marks.md | 5 +-- ...4-web-context-source-and-steer-marks.zh.md | 5 +-- ...ve-steering-interjection-caption.i18n.yaml | 6 ++++ ...eb-remove-steering-interjection-caption.md | 36 +++++++++++++++++++ ...remove-steering-interjection-caption.zh.md | 36 +++++++++++++++++++ .../plan-review/approved.expected.md | 2 +- .../snapshots/steering/mid-steer.expected.md | 2 +- .../snapshots/steering/settled.expected.md | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/MessageItem.module.css | 9 ----- .../src/client/chat/MessageItem.tsx | 12 ++----- .../ui-conversation/src/client/locales.ts | 2 -- .../tests/chat-branch-tails.spec.tsx | 3 +- .../ui-conversation/tests/chat-view.spec.tsx | 4 --- 17 files changed, 97 insertions(+), 39 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md create mode 100644 .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md 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 cdcbcd8d4e..6bc552736e 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: d74badedaa9623014dbba10ae68ca680daf75de1 -2026-08-04-web-context-source-and-steer-marks.zh.md: b2fd990f6f868fdf422ba97ddd25d19705041e3a +2026-08-04-web-context-source-and-steer-marks.md: 0285f3ef1d7cc9dda77322d6b6e61367ee0f12eb +2026-08-04-web-context-source-and-steer-marks.zh.md: 872ab30d7235bae55d4350a98654c5d16e34b968 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 d74badedaa..0285f3ef1d 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 @@ -37,11 +37,12 @@ The transcript names all three roles a non-prompt message can play — injected ## Testing - `packages/client/runtime` unit coverage pins each source kind, the label fallbacks when a name field is missing, empty, or wrongly typed, the unnamed degradation for a source with no readable kind, and steering reconstruction on reset and live append paths. -- `packages/client/ui-conversation` jsdom coverage pins the role title, the producer label beside it, the label's survival while expanded, the roleless header, and the steering caption on both durable and pending bubbles. -- The keyless assembled-Web goldens carry the named header and the steering caption, so the assembled transcript — not only component tests — proves the marks. +- `packages/client/ui-conversation` jsdom coverage pins the role title, the producer label beside it, the label's survival while expanded, and the roleless header. +- The keyless assembled-Web goldens carry the named header, so the assembled transcript — not only component tests — proves the marks. ## Consequences +- **Superseded in part.** The steering-caption clause of the Decision no longer describes master: the [caption removal](../simplification/2026-08-10-web-remove-steering-interjection-caption.md) deleted the `插话` / `Interjection` caption, leaving a mid-turn steer recognizable only by its position in the flow. The context-source and recall naming below stays current, and the `SteeringMessageNode` projection is unchanged. - A reader can attribute every non-prompt message in the transcript at a glance, and the header stays honest for logs this client version has never seen a producer for. - Producer names in the UI are package-shaped (`dsh-tool-skill`, `@deepseek-ai/dsh-system-prompt`) wherever the source carries only a plugin id. That is the cost of refusing a client-side name table; a producer that wants a better label must record one in its source fields. - `ContextMessageNode` gains a required field, so every constructed node — including test fixtures — must supply it. 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 b2fd990f6f..872ab30d72 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 @@ -37,11 +37,12 @@ transcript 为非提示消息可能承担的三种角色分别命名:注入上 ## 测试 - `packages/client/runtime` 单元覆盖钉住每个来源分支、名称字段缺失/为空/类型不符时的回退、来源没有可读 kind 时的无名降级,以及 reset 和实时 append 路径上的 steering 重建。 -- `packages/client/ui-conversation` 的 jsdom 覆盖钉住角色标题、标题旁的生产者名称、展开后该名称的留存、无名时的标题形态,以及持久与待处理气泡上的 steering 标注。 -- 无密钥的组装 Web 黄金基线携带带名称的标题栏与 steering 标注,因此证明这些标识的是组装后的 transcript,而不只是组件测试。 +- `packages/client/ui-conversation` 的 jsdom 覆盖钉住角色标题、标题旁的生产者名称、展开后该名称的留存,以及无名时的标题形态。 +- 无密钥的组装 Web 黄金基线携带带名称的标题栏,因此证明这些标识的是组装后的 transcript,而不只是组件测试。 ## 后果 +- **部分被取代。** 决策中的 steering 标注条款已不再描述 master:[标注移除决策](../simplification/2026-08-10-web-remove-steering-interjection-caption.md)删除了 `插话` / `Interjection` 标注,轮次中途的 steer 只能靠它在消息流中的位置辨认。下列上下文来源与召回命名仍然有效,`SteeringMessageNode` 投影未变。 - 读者一眼即可归因 transcript 中每一条非提示消息;即便面对本客户端版本从未见过其生产者的日志,标题栏依然如实。 - 只要来源仅携带插件 id,UI 中的生产者名称就呈现为包名形态(`dsh-tool-skill`、`@deepseek-ai/dsh-system-prompt`)。这是拒绝客户端名称表的代价;想要更好标签的生产者必须在来源字段中记录该标签。 - `ContextMessageNode` 增加了一个必填字段,因此每一处构造该节点的代码——包括测试 fixture——都必须提供它。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml new file mode 100644 index 0000000000..7194db5780 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md +2026-08-10-web-remove-steering-interjection-caption.md: 2c396f54945fb1c3626f2e5fcc849e81893c23f0 +2026-08-10-web-remove-steering-interjection-caption.zh.md: 85d76e977a908393ba5e3a385b804acce3063660 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md new file mode 100644 index 0000000000..2c396f5494 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md @@ -0,0 +1,36 @@ +# Agent Note: Remove the steering interjection caption + +Status: implemented + +English | [中文](2026-08-10-web-remove-steering-interjection-caption.zh.md) + +## Problem + +The [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) captioned every durable and pending steering bubble with `插话` / `Interjection` so the transcript could say which right-aligned bubble interrupted a running turn. The caption repeats what the flow already shows: a steering bubble sits mid-turn, between the assistant content it interrupted, while a turn-opening prompt sits at a turn boundary. A permanent line of tertiary text above every steer bubble buys no reading a position-aware reader does not already have, and it is the only chrome any user-style bubble carries, so it also breaks the otherwise uniform right-aligned rhythm. + +## Decision + +Steering renders exactly as a user bubble. `UserStyleBubble` has no steering flag, the `message.steering` locale key and the `.steeringMark` style are deleted, and `PendingSteeringBubble` and `UserMessageNodeView` pass only content and actions. A mid-turn steer is recognizable by its position inside the running turn's flow, and by nothing else. + +The runtime distinction is untouched. `SteeringMessageNode` projection from durable `agent/inbox/spliced` history, the `data-pending-steering` attribute, and the pending-to-durable hand-off all remain: the pending lifecycle needs the node identity regardless of presentation, and tests still locate pending bubbles through the attribute. + +This partially supersedes the steering clause of the [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md); its context-source and recall naming stays current. The caption has flipped before: the [archived no-steer decision](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md) removed it while the composer could not steer, and the 2026-08-04 decision reintroduced it after the composer gained a Steer gesture. This removal does not revisit the gesture — steering entry, the Queue dock's steer-send action, and the pending lifecycle keep their owners — it judges only that the transcript need not name the result. + +## Alternatives considered + +**Keep the caption.** It is the status quo and cheap to keep, but it decorates every steer bubble forever to encode a fact the bubble's position already states. Chrome that carries no information a reader lacks is removed, not maintained. + +**Remove the `SteeringMessageNode` distinction too.** The node kind is derived from durable inbox history and drives the pending-to-durable hand-off; it is a replay fact, not presentation. Folding it into `UserMessageNode` would change projection behavior for no UI gain. + +**Distinguish steering with quieter chrome (tint, indent, hover-only label).** Any replacement re-raises the same question with a weaker vocabulary. The distinction the transcript needs is positional and already visible; adding subtler decoration keeps the cost and loses the one virtue the text caption had, being explicit. + +## Testing + +- `packages/client/ui-conversation` jsdom coverage pins the plain bubble: the pending hand-off test locates pending bubbles by `data-pending-steering` and asserts the single-bubble hand-off without any caption, and the MessageItem steering arm asserts copy-without-branch on an uncaptioned bubble. +- The keyless assembled-Web goldens (`steering/mid-steer`, `steering/settled`, `plan-review/approved`) replay the unchanged session fixtures with no caption text. + +## Consequences + +- A replayed transcript no longer names steering: a reader infers a mid-turn interjection from its position inside the turn. That inference is weaker than an explicit label for a reader skimming turn boundaries; the decision accepts this. +- A pending steer bubble is visually identical to an ordinary sent bubble until admission; only its missing clock time differs. +- Reintroducing steering chrome of any form requires a new product decision superseding this note. diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md new file mode 100644 index 0000000000..85d76e977a --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md @@ -0,0 +1,36 @@ +# Agent Note: Remove the steering interjection caption + +Status: implemented + +[English](2026-08-10-web-remove-steering-interjection-caption.md) | 中文 + +## Problem + +[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)给每个持久与待处理的 steering 气泡加上了 `插话` / `Interjection` 标注,让 transcript 能说明哪条右对齐气泡打断了正在运行的轮次。这个标注重复了消息流已经呈现的事实:steering 气泡位于轮次中途、夹在被它打断的助手内容之间,而开轮提示位于轮次边界。在每个 steer 气泡上方常驻一行三级文字,并没有让一个能看到位置的读者多读出任何信息,而且它是所有用户样式气泡中唯一带装饰的,还破坏了原本统一的右对齐节奏。 + +## Decision + +steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标志,`message.steering` locale 键与 `.steeringMark` 样式已删除,`PendingSteeringBubble` 与 `UserMessageNodeView` 只传内容与操作。轮次中途的 steer 只能靠它在运行轮次消息流中的位置辨认,除此之外没有任何标识。 + +运行时的区分保持不变。从持久 `agent/inbox/spliced` 历史投影 `SteeringMessageNode`、`data-pending-steering` 属性、待处理到持久的交接全部保留:待处理生命周期无论呈现如何都需要节点身份,测试也仍通过该属性定位待处理气泡。 + +本决策部分取代[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)中的 steering 条款;其上下文来源与召回命名仍然有效。这个标注此前已经翻转过一次:[已归档的取消 steer 装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)在 composer 无法 steer 时移除了它,2026-08-04 的决策在 composer 获得 Steer 手势后把它加了回来。本次移除不重议手势本身——steering 入口、Queue dock 的插话发送操作、待处理生命周期各归其主——只判定 transcript 不需要为其结果命名。 + +## Alternatives considered + +**保留标注。** 它是现状,维持成本低,但它永久装饰每个 steer 气泡,只为编码气泡位置已经陈述的事实。不承载读者缺少的信息的装饰应当删除,而不是维护。 + +**连 `SteeringMessageNode` 区分一起删。** 节点类型派生自持久 inbox 历史,驱动待处理到持久的交接;它是回放事实,不是呈现。把它并入 `UserMessageNode` 会改变投影行为,却没有任何 UI 收益。 + +**换更安静的装饰(底色、缩进、悬停标签)。** 任何替代装饰都会用更弱的表达重新提出同一个问题。transcript 需要的区分是位置性的、已经可见的;换成更含蓄的装饰保留了成本,却丢掉了文字标注唯一的优点,就是明确。 + +## Testing + +- `packages/client/ui-conversation` 的 jsdom 覆盖固定了纯气泡行为:待处理交接测试通过 `data-pending-steering` 定位待处理气泡,在没有任何标注的前提下断言单气泡交接;MessageItem 的 steering 分支在无标注气泡上断言可复制且无分支操作。 +- 无密钥的组装 Web goldens(`steering/mid-steer`、`steering/settled`、`plan-review/approved`)用未变的会话 fixture 回放,不含标注文字。 + +## Consequences + +- 回放的 transcript 不再为 steering 命名:读者靠消息在轮次中的位置推断这是一次中途插话。对快速扫读轮次边界的读者,这个推断弱于显式标签;本决策接受这一代价。 +- 待处理的 steer 气泡在被准入前与普通已发送气泡在视觉上完全一致,仅缺少时钟时间。 +- 重新引入任何形式的 steering 装饰都需要一个取代本 note 的新产品决策。 diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index c1cae54bb5..f6ef729c79 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - img -- text: "plan Plan mode on. Use /plan off to leave. Interjection Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" +- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img - button "Context injection @deepseek-ai/dsh-system-prompt": diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 5f3f24f709..c60e8cff4f 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -21,7 +21,7 @@ - img - text: Ask question waiting - status: Deep diving... -- text: "Interjection Interjection: include the word BANANA in your final reply." +- text: "Interjection: include the word BANANA in your final reply." - button "Copy": - img - region "Ready to continue?": diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index d598613fa3..bfb72ade1f 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -19,7 +19,7 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" +- text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6c197a5107..dc20ef5753 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: b57f88b5a030a6c20c957e26ea32fb125f106ab4 -README.zh.md: a8a8c4814086cad02c5416078f242ec92a7503d7 +README.md: 1def427a0d6c2d27e0adc7fbfa4a1185bc6b0b57 +README.zh.md: d603e8a337f934f8da9f136f0d42814bb1108acc diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b57f88b5a0..1def427a0d 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,7 +18,7 @@ Approvals take over the composer through the chain this package declares: `Appro The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index a8a8c48140..d603e8a337 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,7 +16,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index c6ca35bb2c..6c86b39e4f 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -8,15 +8,6 @@ gap: 6px; } -/* Steering caption above the bubble: mid-turn interjections carry the same - bubble as a turn-opening prompt, so the transcript names which one this is. */ -.steeringMark { - padding-right: 4px; - color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 16px; -} - .bubble { /* 525px cap inside the 736 column; percentage keeps narrow windows sane. */ max-width: min(525px, 82%); diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a0d85e85e8..49342537ef 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,7 +1,6 @@ // MessageItem: simple chat nodes — user and consumed-steering bubbles -// (right-aligned, with clock + copy IconActions; steering adds the -// interjection caption that names it; branch lives only under assistant -// answers), pending steering (caption + copy only), context injection, +// (right-aligned, with clock + copy IconActions; branch lives only under +// assistant answers), pending steering (copy only), context injection, // compaction marker, retry disclosure, and unknown-surface JSON rows. import { memo, useEffect, useMemo, useState } from 'react' @@ -151,22 +150,19 @@ function projectUserText(text: string): ReactNode { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, actions, pending = false, steering = false, t, + content, actions, pending = false, t, }: { content: readonly unknown[] /** Optional IconActions (or similar) below the bubble; receives the joined text. */ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean - /** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */ - steering?: boolean t: ChatViewSlotProps['t'] }): ReactNode { const { text, rest } = contentText(content) const truncated = (total: number): string => t('json.truncated', { total }) return (
- {steering && {t('message.steering')}}
{projectUserText(text)} {rest.map((block, i) => )} @@ -190,7 +186,6 @@ export function PendingSteeringBubble({ content, t }: { ( ( { expect(vi.getTimerCount()).toBe(0) }) - it('consumed steering is captioned as an interjection and keeps copy without branch', () => { + it('consumed steering renders as a plain user bubble and keeps copy without branch', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -242,7 +242,6 @@ describe('MessageItem arms', () => { } as never} />, ) - expect(view.getByText('插话')).toBeTruthy() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() fireEvent.click(view.getByRole('button', { name: '复制' })) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 6b2072ddfb..4078edfe94 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -459,9 +459,6 @@ describe('ChatView', () => { expect(view.queryByText('later')).toBeNull() const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]') expect(pendingBubble).not.toBeNull() - // Pending and durable steering carry the same interjection caption, so the - // hand-off does not change what the row says it is. - expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy() fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('interrupt now') expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull() @@ -483,7 +480,6 @@ describe('ChatView', () => { }) expect(view.getAllByText('interrupt now')).toHaveLength(1) expect(view.container.querySelector('[data-pending-steering]')).toBeNull() - expect(view.getAllByText('插话')).toHaveLength(1) // Only the durable steering bubble: the turn is still running, so its // assistant narration owns no footer yet, and a steering bubble never // carries a branch action. From 6a72a0873c2d96735c276a74e944946b4e091de9 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 11:08:35 +0800 Subject: [PATCH 057/229] fix(ci): repair fixture turns and module graph --- apps/web/tests/image-display.snapshot.ts | 4 +- apps/web/tests/todo-row.snapshot.ts | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 211 ++++++++++-------- docs/module-graph.zh.md | 211 ++++++++++-------- .../client/connection/src/client/fixture.ts | 22 +- 6 files changed, 242 insertions(+), 212 deletions(-) diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 777cb2236a..6546a79f1b 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Multimodal image surfaces over the BUILT client graph (the code-mode-fixture // idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). -// Opens the fixture history session whose turn 71 carries an image in BOTH a +// Opens the fixture history session whose turn 72 carries an image in BOTH a // user message and an assistant message, and pins the product surfaces: the // history ImageGallery loading real fixture bytes through the authorized // sessions.attachment route, the double-click ImageLightbox, and the composer @@ -12,7 +12,7 @@ import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' installAssembledBootEnv() -/** Open the fixture history session (the alpha log carrying the turn-71 image pair) and wait for its gallery. */ +/** Open the fixture history session (the alpha log carrying the turn-72 image pair) and wait for its gallery. */ async function openFixtureSession(): Promise { const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 }) const group = (await within(tree).findAllByText('fixture')) diff --git a/apps/web/tests/todo-row.snapshot.ts b/apps/web/tests/todo-row.snapshot.ts index c5ded143b3..ff43cee39b 100644 --- a/apps/web/tests/todo-row.snapshot.ts +++ b/apps/web/tests/todo-row.snapshot.ts @@ -2,7 +2,7 @@ // Assembled todo snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the // keyless FixtureApiClient transport, opens the fixture session, and pins the -// two surfaces the fixture's parallel plan (turn 72, two items `in_progress`) +// two surfaces the fixture's parallel plan (turn 73, two items `in_progress`) // reaches — the `todo_write` tool row and the dock's plan strip. // // The row is pinned as three separate fields on purpose. `summary=` is the diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 61e55cd997..4c48dc33f0 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: a248ed4fcb8abc17ffc6982d2733b3c3c2a2a635 -module-graph.zh.md: 28185c255ffa18f3ebc02f177d20594af3356164 +module-graph.md: f9ba64410ef97b12f84a7673c6efaccaa3403a9f +module-graph.zh.md: 3f05d7e03907ebebebacfa5b6eec7312717ddf2b diff --git a/docs/module-graph.md b/docs/module-graph.md index a248ed4fcb..f9ba64410e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -125,6 +125,10 @@ flowchart TD pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] end + subgraph group_attachment["packages/attachment"] + pkg_attachment["attachment"] + pkg_attachment_local["attachment-local"] + end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] end @@ -319,9 +323,8 @@ flowchart TD pkg_type_meta --> pkg_invariants pkg_typert_generator --> pkg_invariants pkg_typert_registry --> pkg_invariants - pkg_llm --> pkg_brand - pkg_llm --> pkg_invariants - pkg_llm --> pkg_timeout + pkg_attachment --> pkg_brand + pkg_attachment --> pkg_invariants pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_hmr --> pkg_client_modules @@ -357,33 +360,16 @@ flowchart TD pkg_subprocess_local --> pkg_timeout pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_environment - pkg_llm_deepseek --> pkg_invariants - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_settings - pkg_llm_deepseek --> pkg_timeout - pkg_llm_pi_ai --> pkg_credentials - pkg_llm_pi_ai --> pkg_environment - pkg_llm_pi_ai --> pkg_invariants - pkg_llm_pi_ai --> pkg_llm - pkg_llm_pi_ai --> pkg_settings - pkg_llm_pi_ai --> pkg_timeout - pkg_session --> pkg_brand - pkg_session --> pkg_invariants - pkg_session --> pkg_llm - pkg_session --> pkg_scope - pkg_session --> pkg_type_meta - pkg_system_prompt --> pkg_invariants - pkg_system_prompt --> pkg_llm - pkg_system_prompt --> pkg_scope - pkg_skill --> pkg_invariants - pkg_skill --> pkg_llm - pkg_web --> pkg_invariants - pkg_web --> pkg_llm + pkg_llm --> pkg_attachment + pkg_llm --> pkg_brand + pkg_llm --> pkg_invariants + pkg_llm --> pkg_timeout pkg_api_gateway --> pkg_client_connection pkg_api_gateway --> pkg_invariants pkg_api_gateway --> pkg_typert_registry + pkg_attachment_local --> pkg_attachment + pkg_attachment_local --> pkg_invariants + pkg_attachment_local --> pkg_paths pkg_client_locale --> pkg_client_runtime pkg_client_locale --> pkg_client_ui_primitives pkg_client_locale --> pkg_client_ui_slots @@ -412,15 +398,70 @@ flowchart TD pkg_credentials_local --> pkg_environment pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths + pkg_settings_local --> pkg_atomic_write + pkg_settings_local --> pkg_invariants + pkg_settings_local --> pkg_paths + pkg_settings_local --> pkg_settings + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_environment + pkg_llm_deepseek --> pkg_invariants + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_settings + pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment + pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_environment + pkg_llm_pi_ai --> pkg_invariants + pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_settings + pkg_llm_pi_ai --> pkg_timeout + pkg_session --> pkg_brand + pkg_session --> pkg_invariants + pkg_session --> pkg_llm + pkg_session --> pkg_scope + pkg_session --> pkg_type_meta + pkg_system_prompt --> pkg_invariants + pkg_system_prompt --> pkg_llm + pkg_system_prompt --> pkg_scope + pkg_skill --> pkg_invariants + pkg_skill --> pkg_llm + pkg_web --> pkg_invariants + pkg_web --> pkg_llm + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_client_web_react + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_slash --> pkg_client_locale + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_primitives + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm - pkg_settings_local --> pkg_atomic_write - pkg_settings_local --> pkg_invariants - pkg_settings_local --> pkg_paths - pkg_settings_local --> pkg_settings pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope @@ -455,40 +496,24 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt - pkg_client_ui_question --> pkg_client_locale - pkg_client_ui_question --> pkg_invariants - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_primitives - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_slots - pkg_client_ui_settings_general --> pkg_client_web_react - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_locale - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_primitives - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime - pkg_client_ui_theme --> pkg_client_ui_primitives - pkg_client_ui_theme --> pkg_client_ui_slots - pkg_client_ui_theme --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox @@ -547,10 +572,6 @@ flowchart TD pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm pkg_loader_smoke --> pkg_session - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -561,16 +582,10 @@ flowchart TD pkg_fs_e2b --> pkg_e2b pkg_fs_e2b --> pkg_fs pkg_fs_e2b --> pkg_invariants - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_commands --> pkg_agent pkg_commands --> pkg_brand pkg_commands --> pkg_invariants @@ -692,10 +707,6 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -1001,6 +1012,8 @@ flowchart TD pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt pkg_client_ui_conversation --> pkg_agent + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -1212,7 +1225,7 @@ flowchart TD | [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | @@ -1227,22 +1240,30 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | -| [`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) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `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) | -| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | -| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`client-locale`](../packages/client/locale) | `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-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-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) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | +| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`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-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`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-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`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-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`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) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1253,13 +1274,10 @@ flowchart TD | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`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-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`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-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`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-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`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-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1275,12 +1293,10 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -1307,7 +1323,6 @@ flowchart TD | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | @@ -1358,7 +1373,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 28185c255f..3f05d7e039 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -127,6 +127,10 @@ flowchart TD pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] end + subgraph group_attachment["packages/attachment"] + pkg_attachment["attachment"] + pkg_attachment_local["attachment-local"] + end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] end @@ -321,9 +325,8 @@ flowchart TD pkg_type_meta --> pkg_invariants pkg_typert_generator --> pkg_invariants pkg_typert_registry --> pkg_invariants - pkg_llm --> pkg_brand - pkg_llm --> pkg_invariants - pkg_llm --> pkg_timeout + pkg_attachment --> pkg_brand + pkg_attachment --> pkg_invariants pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_hmr --> pkg_client_modules @@ -359,33 +362,16 @@ flowchart TD pkg_subprocess_local --> pkg_timeout pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_environment - pkg_llm_deepseek --> pkg_invariants - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_settings - pkg_llm_deepseek --> pkg_timeout - pkg_llm_pi_ai --> pkg_credentials - pkg_llm_pi_ai --> pkg_environment - pkg_llm_pi_ai --> pkg_invariants - pkg_llm_pi_ai --> pkg_llm - pkg_llm_pi_ai --> pkg_settings - pkg_llm_pi_ai --> pkg_timeout - pkg_session --> pkg_brand - pkg_session --> pkg_invariants - pkg_session --> pkg_llm - pkg_session --> pkg_scope - pkg_session --> pkg_type_meta - pkg_system_prompt --> pkg_invariants - pkg_system_prompt --> pkg_llm - pkg_system_prompt --> pkg_scope - pkg_skill --> pkg_invariants - pkg_skill --> pkg_llm - pkg_web --> pkg_invariants - pkg_web --> pkg_llm + pkg_llm --> pkg_attachment + pkg_llm --> pkg_brand + pkg_llm --> pkg_invariants + pkg_llm --> pkg_timeout pkg_api_gateway --> pkg_client_connection pkg_api_gateway --> pkg_invariants pkg_api_gateway --> pkg_typert_registry + pkg_attachment_local --> pkg_attachment + pkg_attachment_local --> pkg_invariants + pkg_attachment_local --> pkg_paths pkg_client_locale --> pkg_client_runtime pkg_client_locale --> pkg_client_ui_primitives pkg_client_locale --> pkg_client_ui_slots @@ -414,15 +400,70 @@ flowchart TD pkg_credentials_local --> pkg_environment pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths + pkg_settings_local --> pkg_atomic_write + pkg_settings_local --> pkg_invariants + pkg_settings_local --> pkg_paths + pkg_settings_local --> pkg_settings + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_environment + pkg_llm_deepseek --> pkg_invariants + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_settings + pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment + pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_environment + pkg_llm_pi_ai --> pkg_invariants + pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_settings + pkg_llm_pi_ai --> pkg_timeout + pkg_session --> pkg_brand + pkg_session --> pkg_invariants + pkg_session --> pkg_llm + pkg_session --> pkg_scope + pkg_session --> pkg_type_meta + pkg_system_prompt --> pkg_invariants + pkg_system_prompt --> pkg_llm + pkg_system_prompt --> pkg_scope + pkg_skill --> pkg_invariants + pkg_skill --> pkg_llm + pkg_web --> pkg_invariants + pkg_web --> pkg_llm + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_client_web_react + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_slash --> pkg_client_locale + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_primitives + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm - pkg_settings_local --> pkg_atomic_write - pkg_settings_local --> pkg_invariants - pkg_settings_local --> pkg_paths - pkg_settings_local --> pkg_settings pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope @@ -457,40 +498,24 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt - pkg_client_ui_question --> pkg_client_locale - pkg_client_ui_question --> pkg_invariants - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_primitives - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_slots - pkg_client_ui_settings_general --> pkg_client_web_react - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_locale - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_primitives - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime - pkg_client_ui_theme --> pkg_client_ui_primitives - pkg_client_ui_theme --> pkg_client_ui_slots - pkg_client_ui_theme --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox @@ -549,10 +574,6 @@ flowchart TD pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm pkg_loader_smoke --> pkg_session - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -563,16 +584,10 @@ flowchart TD pkg_fs_e2b --> pkg_e2b pkg_fs_e2b --> pkg_fs pkg_fs_e2b --> pkg_invariants - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_commands --> pkg_agent pkg_commands --> pkg_brand pkg_commands --> pkg_invariants @@ -694,10 +709,6 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -1003,6 +1014,8 @@ flowchart TD pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt pkg_client_ui_conversation --> pkg_agent + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -1214,7 +1227,7 @@ flowchart TD | [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | @@ -1229,22 +1242,30 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | -| [`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) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `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) | -| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | -| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`client-locale`](../packages/client/locale) | `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-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-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) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | +| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`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-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`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-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`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-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`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) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1255,13 +1276,10 @@ flowchart TD | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`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-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`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-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`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-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`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-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1277,12 +1295,10 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -1309,7 +1325,6 @@ flowchart TD | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | @@ -1360,7 +1375,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 0f640d10a8..422a812397 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1,7 +1,7 @@ // FixtureApi: standalone UI development without a server. Real contract shape: unary takes // RpcRequest

and returns RpcResponse (echoing the rpcId); streams yield RpcRequest // (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse -// and returns RpcReceipt. fx-alpha carries a hand-built history script (73 turns, pageable); +// and returns RpcReceipt. fx-alpha carries a hand-built history script (74 turns, pageable); // prompt triggers a chunked streaming replay; cancel stops the replay; resident pending // approval/question requests exercise replay and composer takeover with stable rpcIds. @@ -351,7 +351,7 @@ function fixtureUsage(turn: number, step: number): TokenUsage { } } -/** fx-alpha history script: 73 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50), +/** fx-alpha history script: 74 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50), * mixing reasoning blocks / tool call+result / context. */ function buildAlphaLog(): SessionEvent[] { const events: Record[] = [] @@ -487,7 +487,7 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - // Turn 72: todo_write sample — the TodoRow toolview in the flow plus the + // Turn 73: todo_write sample — the TodoRow toolview in the flow plus the // todo/write snapshot event feeding the TodoPanel plan strip. Two items are // in_progress: this fixture chooses the parallel policy, so both surfaces // must render a parallel plan rather than the first active item alone. @@ -546,20 +546,20 @@ function buildAlphaLog(): SessionEvent[] { toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.') toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') - // Turn 71: user and assistant images share one durable fixture object. + // Turn 72: user and assistant images share one durable fixture object. // The todo turn remains last so its standing projection stays visible. - push({ type: 'turn/start', data: { turn: 71 } }) + push({ type: 'turn/start', data: { turn: 72 } }) push({ type: 'user/message', surfaceOp: 'append', data: userMessage([{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')]), }) - push({ type: 'step/start', data: { turn: 71, step: 0 } }) + push({ type: 'step/start', data: { turn: 72, step: 0 } }) push({ type: 'assistant/message', surfaceOp: 'append', data: { - turn: 71, + turn: 72, step: 0, message: assistantMessage( [...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }], @@ -567,11 +567,11 @@ function buildAlphaLog(): SessionEvent[] { ), }, }) - push({ type: 'step/end', data: { turn: 71, step: 0 } }) - push({ type: 'turn/end', data: { turn: 71, reason: { kind: 'completed' } } }) + push({ type: 'step/end', data: { turn: 72, step: 0 } }) + push({ type: 'turn/end', data: { turn: 72, reason: { kind: 'completed' } } }) const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') + toolTurn(73, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') // The real tool appends the snapshot mid-execution — between tool/call and // tool/result — so the fixture reproduces that exact ordering (the last // toolTurn events run ... tool/call, tool/result, step/end, turn/end). @@ -1407,7 +1407,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // DeepSeek route so unrelated GUI journeys do not enter first-run setup. ['DEEPSEEK_API_KEY', true], ]) - const nextTurn = new Map([[sid('fx-alpha'), 73]]) + const nextTurn = new Map([[sid('fx-alpha'), 74]]) let nextSession = 1 let nextRpc = 1 let attachedSessions = options.empty ? 0 : 1 From 1db1cda4644dd5df46c149e4c36faba04c9d67d3 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 11:15:01 +0800 Subject: [PATCH 058/229] 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 a3cf2617a8f7fef6846efc0e486478c4f1b70d97 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 11:26:37 +0800 Subject: [PATCH 059/229] =?UTF-8?q?docs(notes):=20fix=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20translate=20zh=20note=20headings,=20point=20supe?= =?UTF-8?q?rsession=20at=20the=20Decision,=20pin=20caption=20absence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...8-04-web-context-source-and-steer-marks.i18n.yaml | 4 ++-- .../2026-08-04-web-context-source-and-steer-marks.md | 2 +- ...26-08-04-web-context-source-and-steer-marks.zh.md | 2 +- ...eb-remove-steering-interjection-caption.i18n.yaml | 2 +- ...10-web-remove-steering-interjection-caption.zh.md | 12 ++++++------ .../ui-conversation/tests/chat-branch-tails.spec.tsx | 1 + 6 files changed, 12 insertions(+), 11 deletions(-) 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 6bc552736e..86b48310e7 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: 0285f3ef1d7cc9dda77322d6b6e61367ee0f12eb -2026-08-04-web-context-source-and-steer-marks.zh.md: 872ab30d7235bae55d4350a98654c5d16e34b968 +2026-08-04-web-context-source-and-steer-marks.md: 01bdca873a847f70b4b8632b961e01e099ae4f04 +2026-08-04-web-context-source-and-steer-marks.zh.md: b6a9cc5692826b402b5a08ec65a5c8fc3c547b6b 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 0285f3ef1d..01bdca873a 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 @@ -42,7 +42,7 @@ The transcript names all three roles a non-prompt message can play — injected ## Consequences -- **Superseded in part.** The steering-caption clause of the Decision no longer describes master: the [caption removal](../simplification/2026-08-10-web-remove-steering-interjection-caption.md) deleted the `插话` / `Interjection` caption, leaving a mid-turn steer recognizable only by its position in the flow. The context-source and recall naming below stays current, and the `SteeringMessageNode` projection is unchanged. +- **Superseded in part.** The steering-caption clause of the Decision no longer describes master: the [caption removal](../simplification/2026-08-10-web-remove-steering-interjection-caption.md) deleted the `插话` / `Interjection` caption, leaving a mid-turn steer recognizable only by its position in the flow. The context-source and recall naming in the Decision stays current, and the `SteeringMessageNode` projection is unchanged. - A reader can attribute every non-prompt message in the transcript at a glance, and the header stays honest for logs this client version has never seen a producer for. - Producer names in the UI are package-shaped (`dsh-tool-skill`, `@deepseek-ai/dsh-system-prompt`) wherever the source carries only a plugin id. That is the cost of refusing a client-side name table; a producer that wants a better label must record one in its source fields. - `ContextMessageNode` gains a required field, so every constructed node — including test fixtures — must supply it. 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 872ab30d72..b6a9cc5692 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 @@ -42,7 +42,7 @@ transcript 为非提示消息可能承担的三种角色分别命名:注入上 ## 后果 -- **部分被取代。** 决策中的 steering 标注条款已不再描述 master:[标注移除决策](../simplification/2026-08-10-web-remove-steering-interjection-caption.md)删除了 `插话` / `Interjection` 标注,轮次中途的 steer 只能靠它在消息流中的位置辨认。下列上下文来源与召回命名仍然有效,`SteeringMessageNode` 投影未变。 +- **部分被取代。** 决策中的 steering 标注条款已不再描述 master:[标注移除决策](../simplification/2026-08-10-web-remove-steering-interjection-caption.md)删除了 `插话` / `Interjection` 标注,轮次中途的 steer 只能靠它在消息流中的位置辨认。决策中的上下文来源与召回命名仍然有效,`SteeringMessageNode` 投影未变。 - 读者一眼即可归因 transcript 中每一条非提示消息;即便面对本客户端版本从未见过其生产者的日志,标题栏依然如实。 - 只要来源仅携带插件 id,UI 中的生产者名称就呈现为包名形态(`dsh-tool-skill`、`@deepseek-ai/dsh-system-prompt`)。这是拒绝客户端名称表的代价;想要更好标签的生产者必须在来源字段中记录该标签。 - `ContextMessageNode` 增加了一个必填字段,因此每一处构造该节点的代码——包括测试 fixture——都必须提供它。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml index 7194db5780..2f63bbcfd7 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md 2026-08-10-web-remove-steering-interjection-caption.md: 2c396f54945fb1c3626f2e5fcc849e81893c23f0 -2026-08-10-web-remove-steering-interjection-caption.zh.md: 85d76e977a908393ba5e3a385b804acce3063660 +2026-08-10-web-remove-steering-interjection-caption.zh.md: 088b36449130f9e8f1be5bec7a3ee4a812b4b6f1 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md index 85d76e977a..088b364491 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md @@ -1,14 +1,14 @@ -# Agent Note: Remove the steering interjection caption +# Agent Note: 移除 steering 插话标注 Status: implemented [English](2026-08-10-web-remove-steering-interjection-caption.md) | 中文 -## Problem +## 问题 [上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)给每个持久与待处理的 steering 气泡加上了 `插话` / `Interjection` 标注,让 transcript 能说明哪条右对齐气泡打断了正在运行的轮次。这个标注重复了消息流已经呈现的事实:steering 气泡位于轮次中途、夹在被它打断的助手内容之间,而开轮提示位于轮次边界。在每个 steer 气泡上方常驻一行三级文字,并没有让一个能看到位置的读者多读出任何信息,而且它是所有用户样式气泡中唯一带装饰的,还破坏了原本统一的右对齐节奏。 -## Decision +## 决策 steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标志,`message.steering` locale 键与 `.steeringMark` 样式已删除,`PendingSteeringBubble` 与 `UserMessageNodeView` 只传内容与操作。轮次中途的 steer 只能靠它在运行轮次消息流中的位置辨认,除此之外没有任何标识。 @@ -16,7 +16,7 @@ steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标 本决策部分取代[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)中的 steering 条款;其上下文来源与召回命名仍然有效。这个标注此前已经翻转过一次:[已归档的取消 steer 装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)在 composer 无法 steer 时移除了它,2026-08-04 的决策在 composer 获得 Steer 手势后把它加了回来。本次移除不重议手势本身——steering 入口、Queue dock 的插话发送操作、待处理生命周期各归其主——只判定 transcript 不需要为其结果命名。 -## Alternatives considered +## 考虑过的替代方案 **保留标注。** 它是现状,维持成本低,但它永久装饰每个 steer 气泡,只为编码气泡位置已经陈述的事实。不承载读者缺少的信息的装饰应当删除,而不是维护。 @@ -24,12 +24,12 @@ steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标 **换更安静的装饰(底色、缩进、悬停标签)。** 任何替代装饰都会用更弱的表达重新提出同一个问题。transcript 需要的区分是位置性的、已经可见的;换成更含蓄的装饰保留了成本,却丢掉了文字标注唯一的优点,就是明确。 -## Testing +## 测试 - `packages/client/ui-conversation` 的 jsdom 覆盖固定了纯气泡行为:待处理交接测试通过 `data-pending-steering` 定位待处理气泡,在没有任何标注的前提下断言单气泡交接;MessageItem 的 steering 分支在无标注气泡上断言可复制且无分支操作。 - 无密钥的组装 Web goldens(`steering/mid-steer`、`steering/settled`、`plan-review/approved`)用未变的会话 fixture 回放,不含标注文字。 -## Consequences +## 后果 - 回放的 transcript 不再为 steering 命名:读者靠消息在轮次中的位置推断这是一次中途插话。对快速扫读轮次边界的读者,这个推断弱于显式标签;本决策接受这一代价。 - 待处理的 steer 气泡在被准入前与普通已发送气泡在视觉上完全一致,仅缺少时钟时间。 diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index bd84f46ed6..5d1f027923 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -242,6 +242,7 @@ describe('MessageItem arms', () => { } as never} />, ) + expect(view.queryByText('插话')).toBeNull() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() fireEvent.click(view.getByRole('button', { name: '复制' })) From fb37107ca4a16d80a036f549aef62146157e2005 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 11:48:49 +0800 Subject: [PATCH 060/229] test(attachment): skip POSIX modes on Windows --- packages/attachment/attachment-local/tests/store.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 2332bfe942..bd2adb4c55 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -116,8 +116,10 @@ describe('local attachment store', () => { }) expect(second.attachmentId).toBe(first.attachmentId) expect(new Uint8Array(await readFile(object))).toEqual(PNG) - expect((await stat(object)).mode & 0o777).toBe(0o600) - expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700) + if (process.platform !== 'win32') { + expect((await stat(object)).mode & 0o777).toBe(0o600) + expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700) + } await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) }) From 12c971d4b9c7841b5b123d82657aef71d6e66929 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 12:06:01 +0800 Subject: [PATCH 061/229] test(attachment): exclude POSIX fsync on Windows --- packages/attachment/attachment-local/src/store.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index bd33e4c8e3..d77f2be375 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -76,12 +76,14 @@ export async function validateImageFile(input: SaveImageAttachment, limits: Imag async function syncDirectory(path: string): Promise { /* v8 ignore next -- Windows cannot open directory handles; NTFS metadata journaling owns entry durability there. */ if (process.platform === 'win32') return + /* v8 ignore start -- Windows cannot exercise directory fsync; POSIX behavior tests enforce this peer. */ const handle = await open(path, constants.O_RDONLY) try { await handle.sync() } finally { await handle.close() } + /* v8 ignore stop */ } /** From 64e0fbfd6d08415200cc3ea0947ee418c5b7bd0e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 12:16:19 +0800 Subject: [PATCH 062/229] fix(subagent): inherit parent policy overrides in continuable children A continuable background child (the default backgroundMode for both delegation tools) never received the parent session's explicit sandbox/approval overrides: materialization applied only child composition, so a danger-full-access parent produced workspace-write children whose every out-of-workspace operation raised an approval prompt. Move the one-shot driver's capture/append pair into the shared child-agent module (captureDelegatedPolicyOverrides / appendDelegatedPolicyOverrides) and call it from both paths: startContinuable captures before its first await, only fresh materialization appends the source-tagged events (after any fork seed), and a cold resume replays the persisted delegation events instead of re-capturing the parent. Adds the continuable inheritance unit suite, the ACP snapshot scenario subagent-continuable-inheritance (fails without the fix), the continuable policy-inheritance Agent Note, and the seam-level README contract, with bilingual counterparts. Fixes #1692 --- ...7-25-subagent-policy-inheritance.i18n.yaml | 4 +- .../2026-07-25-subagent-policy-inheritance.md | 4 +- ...26-07-25-subagent-policy-inheritance.zh.md | 4 +- ...able-subagent-policy-inheritance.i18n.yaml | 6 + ...continuable-subagent-policy-inheritance.md | 29 + ...tinuable-subagent-policy-inheritance.zh.md | 29 + 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 | 10 +- docs/subsystems/subagent.zh.md | 10 +- ...ontinuable-inheritance.cordis.snapshot.yml | 46 ++ ...ubagent-continuable-inheritance.cordis.yml | 11 + examples/acp-agent/tests/acp.snapshot.ts | 15 + .../tests/fixtures/parent-sandbox-override.ts | 19 + .../input.json | 19 + .../session.1.jsonl | 21 + .../session.jsonl | 29 + .../stdout.expected.jsonl | 4 + .../tool-schemas.1.expected.json | 543 ++++++++++++++++++ knip.json | 1 + .../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 | 20 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 4 + packages/subagent/subagent/README.zh.md | 4 + packages/subagent/subagent/package.json | 15 + packages/subagent/subagent/src/child-agent.ts | 61 +- .../subagent/subagent/src/continuation.ts | 30 +- packages/subagent/subagent/src/index.ts | 4 +- .../tests/continuation-inheritance.spec.ts | 171 ++++++ packages/subagent/subagent/tsconfig.json | 9 + pnpm-lock.yaml | 9 + 36 files changed, 1106 insertions(+), 61 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md create mode 100644 .agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md create mode 100644 examples/acp-agent/subagent-continuable-inheritance.cordis.snapshot.yml create mode 100644 examples/acp-agent/subagent-continuable-inheritance.cordis.yml create mode 100644 examples/acp-agent/tests/fixtures/parent-sandbox-override.ts create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json create mode 100644 packages/subagent/subagent/tests/continuation-inheritance.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index 0308005515..616a45e1d1 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md -2026-07-25-subagent-policy-inheritance.md: aeff83795eedead9c75de6bbb74c1da1945092ca -2026-07-25-subagent-policy-inheritance.zh.md: c26e6bf8b79c86855022c384673957fe04ff761d +2026-07-25-subagent-policy-inheritance.md: a2f4d578de857ee63df5e7741c433210d5f1ef86 +2026-07-25-subagent-policy-inheritance.zh.md: f069bf290586447afc0b7d46a41ad4de9bfcbe8f diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index aeff83795e..a2f4d578de 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -10,7 +10,7 @@ Sandbox and approval overrides are per-session log folds. An in-process subagent ## Decision -The shared in-process driver snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` before its first await. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. Both services are optional, and only explicit session overrides are copied, never deployment defaults or one-shot grants. +The delegation boundary snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` before its first await, through the shared child-agent helpers (`captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides` in `dsh-subagent`), which the one-shot driver and the [continuable start](2026-08-10-continuable-subagent-policy-inheritance.md) both call. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. Both services are optional, and only explicit session overrides are copied, never deployment defaults or one-shot grants. Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event appended during the child factory's unpublished setup. The session constructor has already fixed `Session.firstLiveSeq` at the fork-prefix length, so the inherited facts follow fork history, reach telemetry when the child is announced, and leave `SessionHeader.seedLength` at the prefix length. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's logged state, so the rule composes without another inheritance mechanism. @@ -33,4 +33,4 @@ A confined child gets the ordinary denial marker. No answerer currently owns an - Spawn, fork, and nested in-process children retain a parent's explicit sandbox and approval overrides. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, the live-event boundary, default omission, and context disposal. - The keyless headless snapshot is the assembled regression: only the parent is `read-only`, the deployment default is `workspace-write`, and the child's persisted event plus denied disk write both fail if capture is removed. -- Each delegation adds at most two log-only events. `dsh-subagent-inprocess` has optional peer types for the two policy services; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches. +- Each delegation adds at most two log-only events. `dsh-subagent` and `dsh-subagent-inprocess` have optional peer types for the two policy services; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index c26e6bf8b7..f069bf2905 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -共享的进程内驱动器在第一次 await 之前对 `sandboxPolicy.overrideOf(parent.session)` 和 `approval.overrideOf(parent.session)` 获取快照。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。这两个服务均为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。 +委派边界在第一次 await 之前,经由共享的子 agent 辅助函数(`dsh-subagent` 中的 `captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides`)对 `sandboxPolicy.overrideOf(parent.session)` 和 `approval.overrideOf(parent.session)` 获取快照;一次性驱动器与[可继续启动](2026-08-10-continuable-subagent-policy-inheritance.md)都会调用这些辅助函数。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。这两个服务均为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。 每个捕获值都会成为子 agent 工厂在未发布设置阶段追加的一条带来源标记的 `sandbox/mode` 或 `approval/policy` 事件。会话构造函数已将 `Session.firstLiveSeq` 固定为 fork 前缀的长度,因此继承事实会排在 fork 历史之后,在子 agent 公布时进入遥测,同时让 `SessionHeader.seedLength` 保持为此前缀的长度。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已记录的状态,因此无需另一套继承机制即可组合此规则。 @@ -33,4 +33,4 @@ Status: implemented - spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱与审批覆盖项。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、实时事件边界、默认值省略与上下文释放。 - 无密钥 headless 快照是组装后应用层面的回归测试:只有父级是 `read-only`,部署默认值是 `workspace-write`;若移除捕获,子 agent 的持久化事件与被拒的磁盘写入这两项检查都会失败。 -- 每次委派最多增加两条仅日志事件。`dsh-subagent-inprocess` 为两个策略服务提供可选 peer 类型;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。 +- 每次委派最多增加两条仅日志事件。`dsh-subagent` 和 `dsh-subagent-inprocess` 为两个策略服务提供可选 peer 类型;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。 diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml new file mode 100644 index 0000000000..dc23421912 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md +2026-08-10-continuable-subagent-policy-inheritance.md: 39df910a920e6995ba6048fdd2613d2c216f5ec2 +2026-08-10-continuable-subagent-policy-inheritance.zh.md: 2a977eaa9aade189213fdd20a7d888e8e62efb48 diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md new file mode 100644 index 0000000000..39df910a92 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md @@ -0,0 +1,29 @@ +# Agent Note: Continuable subagent policy inheritance — the durable child log owns the delegation-time snapshot + +Status: implemented + +English | [中文](2026-08-10-continuable-subagent-policy-inheritance.zh.md) + +## Problem + +The one-shot in-process driver has seeded parent sandbox/approval overrides into its children since the [in-process policy-inheritance decision](2026-07-25-subagent-policy-inheritance.md), but the continuable path never did: `SubagentContinuationManager` materialization applied only child composition and the activation setup registry. The default bundle wires both delegation tools as `backgroundMode: continuable`, so in a default deployment every background child silently fell back to deployment defaults — a parent switched to `danger-full-access` produced children stuck at `workspace-write` whose every out-of-workspace operation raised an approval prompt, and a parent's unattended `'never'` approval stance reverted to prompting ([dsh-external/issues#334](https://github.com/dsh-external/issues/issues/334)). + +## Decision + +The capture/append pair moved from the one-shot driver into the seam's shared child-agent module (`dsh-subagent/src/child-agent.ts`), the declared one home for shared child composition: `captureDelegatedPolicyOverrides(parent)` snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` through optional `ctx.get`, and `appendDelegatedPolicyOverrides(childSession, overrides)` appends the `source: 'delegation'` events. The one-shot driver and the continuation manager both call them, so the two paths cannot drift. + +`startContinuable` captures before its first await (`prepareContinuable`), the same "a later parent switch belongs to the parent's future" boundary as one-shot. The snapshot travels in `MaterializeInputs.create`, so only fresh materialization appends the events during unpublished setup, after any fork seed. A cold resume passes no `create` inputs and appends nothing: the persisted child log already carries the delegation events, and replaying the log IS the state. The durable child log — not the current Activation, not the resuming parent — owns the child's effective policy, so a parent switch between residency epochs never retroactively changes a durable child. + +## Alternatives considered + +- **An activation-setup-registry contribution** (`registerContinuableSetup`) — rejected: a contribution receives only the child context, so it cannot capture the parent's overrides at the delegation boundary; the registry applies on cold resume as well as fresh creation, which would re-append or re-capture; and nothing ties a contribution's capture to the start call's synchronous prefix, so the pre-await capture guarantee would be lost. +- **Re-capturing the parent's overrides at cold resume** — rejected: a resumed child would silently change policy with the parent's later switches, breaking the snapshot-at-delegation semantic and making effective policy depend on resume timing instead of the child's own log. A parent that wants a resumed child under new policy re-delegates. +- **Importing the one-shot driver's inline logic from the continuation manager** — rejected: the Service Definition package cannot depend on its own provider package, and duplicating the capture/append pair in `continuation.ts` invites drift; `child-agent.ts` already holds every other shared composition step. +- **Seeding the events into the descriptor seed turn** — rejected: the capture value is not known when the seed is assembled for every caller, and the one-shot precedent already establishes unpublished-setup appends as the ordering that places inherited facts after fork history with `firstLiveSeq` intact. + +## Consequences + +- Default-bundle background delegation (`backgroundMode: continuable`) now inherits a parent's explicit sandbox and approval overrides; compositions without either policy service behave unchanged. +- `dsh-subagent` gains optional peer types on `dsh-sandbox-policy` and `dsh-user-approval` (the `ctx.get` pattern the one-shot driver used); `dsh-subagent-inprocess` keeps its optional peers but delegates to the shared helpers. +- The continuable suite (`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`) pins fresh-start seeding, pre-await capture, default omission, cold-resume snapshot stability, and fork-seed precedence; the ACP snapshot scenario `subagent-continuable-inheritance` pins the child's delegation event and read-only runtime context through the assembled app and fails when the capture is removed. +- Out-of-process providers (`acp`, `dsh-sdk`, `claude-code`, `codex`) support no continuable children (`prepareContinuable` absent), and their one-shot children keep their own deployment policy (`inheritsParentContext = false`); cross-process policy propagation remains out of scope. diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md new file mode 100644 index 0000000000..2a977eaa9a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 可继续 subagent 策略继承——持久化子日志拥有委派时快照 + +Status: implemented + +[English](2026-08-10-continuable-subagent-policy-inheritance.md) | 中文 + +## 问题 + +自[进程内策略继承决策](2026-07-25-subagent-policy-inheritance.md)以来,一次性进程内驱动器一直会把父级的沙箱/审批覆盖项注入其子级,但可继续路径从未这样做:`SubagentContinuationManager` 的物化只应用子级组合与 Activation(激活)设置注册表。默认组合包把两个委派工具都配置为 `backgroundMode: continuable`,因此在默认部署中,每个后台子 agent(智能体)都静默回退到部署默认值:切换到 `danger-full-access` 的父级产出的子 agent 卡在 `workspace-write`,每次工作区外操作都会触发审批提示;父级无人值守的 `'never'` 审批立场也退回为发起提示的行为([dsh-external/issues#334](https://github.com/dsh-external/issues/issues/334))。 + +## 决策 + +捕获/追加这对函数从一次性驱动器移入该 seam 的共享子 agent 模块(`dsh-subagent/src/child-agent.ts`),即声明的共享子级组合唯一归属之处:`captureDelegatedPolicyOverrides(parent)` 通过可选的 `ctx.get` 对 `sandboxPolicy.overrideOf(parent.session)` 与 `approval.overrideOf(parent.session)` 建立快照,`appendDelegatedPolicyOverrides(childSession, overrides)` 则追加 `source: 'delegation'` 事件。一次性驱动器与继续执行管理器都调用它们,因此两条路径不会出现偏差。 + +`startContinuable` 在其第一次 await(`prepareContinuable`)之前完成捕获,沿用与一次性路径相同的「父级后续切换属于父级的未来」边界。快照放在 `MaterializeInputs.create` 中传递,因此只有全新物化会在未发布的设置阶段、排在任何 fork 种子之后追加这些事件。冷恢复(cold resume)不传入 `create` 输入,也不追加任何内容:持久化的子日志已经携带委派事件,而回放该日志本身就是状态。子 agent 的生效策略由持久化子日志拥有,而不是当前 Activation,也不是发起恢复的父级,因此父级在驻留纪元(residency epoch)之间的切换绝不会追溯性地改变一个持久化子 agent。 + +## 考虑过的替代方案 + +- **一项 Activation 设置注册表贡献**(`registerContinuableSetup`):不予采纳。贡献只接收子级上下文,因此无法在委派边界捕获父级的覆盖项;该注册表在冷恢复与全新创建时都会应用,会导致重复追加或重复捕获;而且没有任何机制把贡献的捕获绑定到 start 调用的同步前缀,await 前捕获的保证会因此丢失。 +- **在冷恢复时重新捕获父级覆盖项**:不予采纳。恢复的子 agent 会随父级后续切换静默改变策略,这会破坏委派时快照的语义,并让生效策略取决于恢复时机而非子级自身的日志。希望恢复的子 agent 采用新策略的父级应重新委派。 +- **让继续执行管理器导入一次性驱动器的内联逻辑**:不予采纳。Service Definition 包不能依赖自己的提供方包,而在 `continuation.ts` 中复制捕获/追加这对函数会招致偏差;`child-agent.ts` 已经承载其余每个共享组合步骤。 +- **把这些事件写入描述符种子轮次**:不予采纳。种子为每个调用方组装时,捕获值尚不可知;而且一次性路径的先例已经确立:在未发布的设置阶段追加,才是把继承事实排在 fork 历史之后、同时保持 `firstLiveSeq` 不变的顺序。 + +## 后果 + +- 默认组合包的后台委派(`backgroundMode: continuable`)现在会继承父级显式的沙箱与审批覆盖项;未组合任一策略服务的组合保持原有行为。 +- `dsh-subagent` 新增针对 `dsh-sandbox-policy` 与 `dsh-user-approval` 的可选 peer 类型(即一次性驱动器所用的 `ctx.get` 模式);`dsh-subagent-inprocess` 保留自己的可选 peer,但委托给共享辅助函数。 +- 可继续测试套件(`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`)锁定全新启动的种子写入、await 前捕获、默认值省略、冷恢复快照稳定性与 fork 种子优先级;ACP 快照场景 `subagent-continuable-inheritance` 经组装后的应用锁定子级的委派事件与只读运行时上下文,移除捕获时即失败。 +- 进程外提供方(`acp`、`dsh-sdk`、`claude-code`、`codex`)不支持可继续子 agent(没有 `prepareContinuable`),其一次性子 agent 保留自身的部署策略(`inheritsParentContext = false`);跨进程策略传播仍不在范围内。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 0a3d6007f1..1f2e85c3d6 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: e238734189d6553f9008d958bafbf9556ee23bff +event-producer-consumer.zh.md: b74a1ed334919fd6db1187d7f0e07e2b6ddc5221 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 11eecf81a4..e238734189 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:164`](../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:138`](../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:144`](../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:155`](../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..b74a1ed334 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:164`](../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:138`](../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:144`](../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:155`](../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..06b2b87fad 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: b554bb20180b8784e38ac14fb83769eb354b53ae +subagent.zh.md: 14e2b9b3e571c97384ccf560ce23edbeda62a305 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 99c54e0696..b554bb2018 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -613,7 +613,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:169`](../../packages/subagent/subagent/src/index.ts) @@ -639,7 +639,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:164`](../../packages/subagent/subagent/src/index.ts) @@ -656,7 +656,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:138`](../../packages/subagent/subagent/src/index.ts) @@ -673,7 +673,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:144`](../../packages/subagent/subagent/src/index.ts) @@ -697,5 +697,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:155`](../../packages/subagent/subagent/src/index.ts) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index c5a79247a8..14e2b9b3e5 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -615,7 +615,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:169`](../../packages/subagent/subagent/src/index.ts) @@ -641,7 +641,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:164`](../../packages/subagent/subagent/src/index.ts) @@ -658,7 +658,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:138`](../../packages/subagent/subagent/src/index.ts) @@ -675,7 +675,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:144`](../../packages/subagent/subagent/src/index.ts) @@ -699,5 +699,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:155`](../../packages/subagent/subagent/src/index.ts) diff --git a/examples/acp-agent/subagent-continuable-inheritance.cordis.snapshot.yml b/examples/acp-agent/subagent-continuable-inheritance.cordis.snapshot.yml new file mode 100644 index 0000000000..089f1e3ae3 --- /dev/null +++ b/examples/acp-agent/subagent-continuable-inheritance.cordis.snapshot.yml @@ -0,0 +1,46 @@ +# Keyless counterpart to subagent-continuable-inheritance.cordis.yml: replace +# the live adapter with replay and switch the root session to read-only at +# creation. +- id: base + name: '@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. + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: parent-sandbox-override + name: './tests/fixtures/parent-sandbox-override.ts' diff --git a/examples/acp-agent/subagent-continuable-inheritance.cordis.yml b/examples/acp-agent/subagent-continuable-inheritance.cordis.yml new file mode 100644 index 0000000000..5ad395650d --- /dev/null +++ b/examples/acp-agent/subagent-continuable-inheritance.cordis.yml @@ -0,0 +1,11 @@ +# Policy-inheritance overlay: the root session is switched to read-only at +# creation (the UI Access switch equivalent), so a continuable background +# child must inherit that override instead of the deployment default. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: parent-sandbox-override + name: './tests/fixtures/parent-sandbox-override.ts' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index af044fc58f..4add3f82e8 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -47,6 +47,9 @@ const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml' const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath( new URL('../subagent-durability-failure.cordis.yml', import.meta.url), ) +const SUBAGENT_CONTINUABLE_INHERITANCE_CONFIG = fileURLToPath( + new URL('../subagent-continuable-inheritance.cordis.yml', import.meta.url), +) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) @@ -315,6 +318,18 @@ const SCENARIOS: Scenario[] = [ pinsChildToolSchemas: [1], configPath: SUBAGENT_DURABILITY_FAILURE_CONFIG, }, + // Authored policy-inheritance transcript: the root session is switched to + // read-only at creation (the UI Access switch equivalent), and the + // continuable background child's log carries that override as a + // `sandbox/mode` `source: 'delegation'` event, so the child's runtime + // context states the inherited policy instead of the deployment default. + { + name: 'subagent-continuable-inheritance', + hasModelTurn: true, + recorded: false, + pinsChildToolSchemas: [1], + configPath: SUBAGENT_CONTINUABLE_INHERITANCE_CONFIG, + }, // The in-process child is published before its first follow-up fails. The // foreground tool retains both that run-result failure and an independent // published-handle disposal failure. diff --git a/examples/acp-agent/tests/fixtures/parent-sandbox-override.ts b/examples/acp-agent/tests/fixtures/parent-sandbox-override.ts new file mode 100644 index 0000000000..02698d58c6 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/parent-sandbox-override.ts @@ -0,0 +1,19 @@ +import type { Context } from 'cordis' +import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-agent' + +export const name = 'parent-sandbox-override' + +/** + * Snapshot-only overlay: switch each ROOT session to `read-only` at creation — + * the UI "Access" switch equivalent (one runtime `sandbox/mode` event on the + * session log) — so the scenario proves a continuable background child + * inherits the parent's explicit override as a `source: 'delegation'` event + * instead of falling back to the deployment default. + */ +export function apply(ctx: Context): void { + ctx.on('agent/created', ({ agent }) => { + if (agent.session.header.parentSession !== undefined) return + setSandboxMode(agent.session, 'read-only') + }) +} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json new file mode 100644 index 0000000000..183e21b557 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json @@ -0,0 +1,19 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Reply with the single word DONE. Do not use the bash tool." + }, + { + "op": "waitForSubagentTurnEnd", + "child": 1, + "minimumTurn": 1 + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl new file mode 100644 index 0000000000..478198cf0c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl @@ -0,0 +1,21 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} +{"type":"subagent/descriptor","seq":0,"time":1786333735890,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"session/end-seed","seq":1,"time":1786333735890,"data":{}} +{"type":"sandbox/mode","seq":2,"time":1786333735890,"data":{"mode":"read-only","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":3,"time":1786333735891,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"}]}} +{"type":"turn/start","seq":4,"time":1786333735891,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":1786333735891,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":6,"time":1786333735916,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":7,"time":1786333735916,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"},"surfaceOp":"append"} +{"type":"user/message","seq":8,"time":1786333735916,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"5f181e93-3fd7-40f3-b5f7-21674b53d7c7"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":1786333735916,"data":{"title":"Reply with exactly the word","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":1786333735916,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":1786333735916,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":12,"time":1786333735920,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":13,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":14,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":15,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":16,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":17,"time":1786333735921,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e2b94008-b067-4ca7-a576-6b4a9060cd83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1786333735921,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":19,"time":1786333735921,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl new file mode 100644 index 0000000000..0968357f90 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl @@ -0,0 +1,29 @@ +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"sandbox/mode","seq":0,"time":1786333735842,"data":{"mode":"read-only"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786333735845,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"d554122c-d857-4de0-aea0-6452f260d032"}]}} +{"type":"turn/start","seq":2,"time":1786333735845,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786333735845,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":4,"time":1786333735878,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1786333735878,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"d554122c-d857-4de0-aea0-6452f260d032"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786333735878,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"9a793bfc-c155-44f8-ba56-c6843338d6be"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786333735878,"data":{"title":"Follow these steps exactly, then","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1786333735879,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1786333735879,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1789000000000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":11,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} +{"type":"assistant/chunk","seq":12,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} +{"type":"assistant/chunk","seq":13,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":14,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":15,"time":1786333735884,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8ab58a42-e74c-4121-a6ca-63696e592287"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"tool/call","seq":16,"time":1786333735885,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} +{"type":"tool/result","seq":17,"time":1786333735892,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"3478555e-f0d0-4ec1-a7e4-a15ab24b9ecf"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1786333735892,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":19,"time":1786333735897,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":20,"time":1786333735903,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":21,"time":1786333735903,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":22,"time":1786333735903,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":23,"time":1786333735904,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":24,"time":1786333735904,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":25,"time":1786333735904,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4057a08e-b50e-45e7-beb0-c74485f2b7d6"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} +{"type":"step/end","seq":26,"time":1786333735904,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":27,"time":1786333735904,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json new file mode 100644 index 0000000000..7dd791cf27 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json @@ -0,0 +1,543 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "report", + "description": "Report selected content to the agent that started you. Call this zero or more times for progress, findings, or a final answer. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.", + "parameters": { + "type": "object", + "properties": { + "output": { + "type": "string", + "description": "Self-contained content for your parent; it does not see your private work." + } + }, + "required": [ + "output" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/knip.json b/knip.json index 812f7446bc..9a091a068e 100644 --- a/knip.json +++ b/knip.json @@ -48,6 +48,7 @@ "headless-agent/tests/fixtures/e2b/e2b/bin.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "acp-agent/tests/fixtures/child-question-tripwire.ts", + "acp-agent/tests/fixtures/parent-sandbox-override.ts", "acp-agent/tests/fixtures/partial-landlock-sandbox.ts", "acp-agent/tests/fixtures/subagent-durability-failure.ts", "acp-agent/tests/fixtures/subagent-settlement-marker.ts", diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index a6a82fb47f..7598b6dc3e 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: 4189979806ccd4e7dcfee231ab3e9e2331550b0d +README.zh.md: c6a9005cbfdb9d40a54383f921671fa22a31dc32 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 67f0cf5dd1..4189979806 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -20,7 +20,7 @@ The child gets the parent's working-directory/session lineage and inherits the p This result boundary is valid because the provider owns an isolated child lifecycle from publication through quiescence. Steering submitted during that lifecycle belongs to the child run; the provider does not pretend the initial follow-up alone owns its output. -When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). +The driver applies the seam's [delegated policy inheritance](../subagent/README.md#delegated-policy-inheritance) through the shared child-agent helpers: it captures the parent's explicit sandbox/approval overrides before child creation and appends the source-tagged events during unpublished setup, after any fork history and before session publication. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). ## Cancellation and ownership diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 648a160be5..c6a9005cbf 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -20,7 +20,7 @@ 该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。 -当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 +驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略继承](../subagent/README.md#delegated-policy-inheritance):它会在创建子 agent 前捕获父级的显式沙箱/审批覆盖项,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 ## 取消与所有权 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index acb4e4d36e..f3e4bb04d2 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -17,8 +17,10 @@ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { + appendDelegatedPolicyOverrides, applyChildComposition, assertSubagentMaxDepth, + captureDelegatedPolicyOverrides, childSessionMeta, resolveChildAgentOptions, resolveChildDepth, @@ -30,11 +32,6 @@ import type { SubagentRun, SubagentStopReason, } from '@deepseek-ai/dsh-subagent' -// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve -// to the policy services when composed — the driver consumes both -// opportunistically (the documented `ctx.get` pattern), never as a hard dep. -import type {} from '@deepseek-ai/dsh-sandbox-policy' -import type {} from '@deepseek-ai/dsh-user-approval' import { attachStructuredRuntime, type StructuredAttachment, @@ -111,20 +108,11 @@ export async function startInProcessRun( // Capture before the first await: a later parent switch belongs to the // parent's future. - const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session) - const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session) + const inherited = captureDelegatedPolicyOverrides(parent) let structured: StructuredAttachment | undefined const setup = (childCtx: Context): void => { - // Inherited overrides land on the child's own log, so its effective policy - // is reconstructable from that log alone. - const childSession = (childCtx.agent as Agent).session - if (inheritedMode !== undefined) { - childSession.append('sandbox/mode', { mode: inheritedMode, source: 'delegation' }) - } - if (inheritedPolicy !== undefined) { - childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' }) - } + appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, inherited) applyChildComposition(childCtx, { persona: request.persona, toolFilter: request.toolFilter, diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 1241aa8042..7c64fb7228 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: 6cea175de3d07290b293ad55ccbe60d7d918d37c +README.zh.md: c5fecd554357146d317b5f75824f40a1cb976f2c diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 762030629c..6cea175de3 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -52,6 +52,10 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th `inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and the out-of-process one-shot providers do not), not whether it inherits tools, services, or authority. +## Delegated policy inheritance + +Both in-process delegation paths seed the parent's explicit policy overrides into the child through the shared child-agent helpers: `captureDelegatedPolicyOverrides(parent)` snapshots `sandboxPolicy.overrideOf()` and `approval.overrideOf()` synchronously at the delegation boundary (both services are optional `ctx.get` consumers), and `appendDelegatedPolicyOverrides()` writes each captured value onto the child's own log as a `source: 'delegation'` `sandbox/mode` or `approval/policy` event during unpublished setup, after any fork seed — so fresh policy wins stale seed state, a later child switch wins the snapshot, and the child's effective policy stays reconstructable from its log alone. Deployment defaults are never copied: an unswitched parent stamps nothing and its child follows the deployment default dynamically. A continuable start captures before its first await and seeds only fresh materialization; a cold resume replays the persisted delegation events instead of re-capturing the parent, so a parent switch after creation never retroactively changes a durable child. See the [one-shot](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md) and [continuable](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md) policy-inheritance Agent Notes. + ## One-shot ownership and lifecycle `provider.start(request): Promise` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 535cc25895..c5fecd5543 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -52,6 +52,10 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。 +## 委派策略继承 + +两条进程内委派路径都会通过共享的子 agent 辅助函数,把父级的显式策略覆盖项作为种子注入子 agent:`captureDelegatedPolicyOverrides(parent)` 在委派边界同步对 `sandboxPolicy.overrideOf()` 与 `approval.overrideOf()` 获取快照(这两个服务都是可选的 `ctx.get` 消费方),`appendDelegatedPolicyOverrides()` 则在未发布的设置阶段、在任何 fork 种子之后,把每个捕获值作为一条 `source: 'delegation'` 的 `sandbox/mode` 或 `approval/policy` 事件写入子 agent 自己的日志:因此新鲜策略压过陈旧的种子状态,子 agent 后续的切换压过该快照,而子 agent 的生效策略始终可以仅凭其日志重建。部署默认值绝不复制:未切换的父级不会记录任何值,其子 agent 会动态跟随部署默认值。可继续启动会在其第一次 await 之前捕获,并且只为新鲜的物化写入种子;冷恢复会重放已持久化的委派事件,而不是重新捕获父级,因此创建之后的父级切换绝不会追溯性地改变持久化子 agent。参见[一次性](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)与[可继续](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md)两篇策略继承 Agent Note。 + ## 一次性所有权与生命周期 `provider.start(request): Promise` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`。 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index ba1dd0fbc4..fc03f20b1a 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -37,6 +37,8 @@ "@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", @@ -44,9 +46,16 @@ "@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", "cordis": "^4.0.0-rc.7" }, "peerDependenciesMeta": { + "@deepseek-ai/dsh-sandbox": { + "optional": true + }, + "@deepseek-ai/dsh-sandbox-policy": { + "optional": true + }, "@deepseek-ai/dsh-session-persistence": { "optional": true }, @@ -58,6 +67,9 @@ }, "@deepseek-ai/dsh-tasks": { "optional": true + }, + "@deepseek-ai/dsh-user-approval": { + "optional": true } }, "devDependencies": { @@ -65,6 +77,8 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", @@ -74,6 +88,7 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index c477954468..cb4e8fbd97 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -1,17 +1,23 @@ /** * Shared in-process child composition: the delegation-depth budget, the - * durable session metadata, the resolved child `AgentOptions`, and the scoped - * setup a child agent needs. Both the one-shot provider driver and the - * continuation manager compose children this way, so depth accounting and - * lineage stamping have one home. + * durable session metadata, the resolved child `AgentOptions`, the delegated + * policy snapshot, and the scoped setup a child agent needs. Both the one-shot + * provider driver and the continuation manager compose children this way, so + * depth accounting, lineage stamping, and policy inheritance have one home. * * @module @deepseek-ai/dsh-subagent/child-agent */ import type { Context } from 'cordis' import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' +import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve +// to the policy services when composed — delegation consumes both +// opportunistically (the documented `ctx.get` pattern), never as a hard dep. +import type {} from '@deepseek-ai/dsh-sandbox-policy' import { delegationDepthOf } from './depth.ts' /** Thrown when starting a child would exceed the requested depth cap. */ @@ -119,6 +125,51 @@ export function applyChildComposition(childCtx: Context, composition: ChildCompo if (composition.toolFilter !== undefined) childCtx.tools.restrict(composition.toolFilter) } +/** Parent-session policy overrides captured at the delegation boundary. */ +export interface DelegatedPolicyOverrides { + /** The parent session's explicit sandbox-mode override, or `undefined` without one. */ + readonly sandboxMode: SandboxMode | undefined + /** The parent session's explicit approval-policy override, or `undefined` without one. */ + readonly approvalPolicy: ApprovalPolicy | undefined +} + +/** + * Capture the parent session's explicit policy overrides for one delegation. + * Call synchronously before the child start's first await: a later parent + * switch belongs to the parent's future, not to this child. Deployment + * defaults and one-shot grants are never captured, so an unswitched parent + * leaves the child following the deployment default dynamically. + * @param parent - the delegating parent agent. + * @returns the overrides to seed into the child, each `undefined` without one. + */ +export function captureDelegatedPolicyOverrides(parent: Agent): DelegatedPolicyOverrides { + return { + sandboxMode: parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session), + approvalPolicy: parent.ctx.get('approval')?.overrideOf(parent.session), + } +} + +/** + * Append captured parent overrides onto the child's own log as + * `source: 'delegation'` events inside the unpublished creation window, so the + * child's effective policy is reconstructable from its log alone. Appends land + * after any fork seed, so fresh policy wins stale seed state; later child + * switches still win over these events. + * @param childSession - the unpublished child's session. + * @param overrides - the overrides captured at delegation. + */ +export function appendDelegatedPolicyOverrides( + childSession: Session, + overrides: DelegatedPolicyOverrides, +): void { + if (overrides.sandboxMode !== undefined) { + childSession.append('sandbox/mode', { mode: overrides.sandboxMode, source: 'delegation' }) + } + if (overrides.approvalPolicy !== undefined) { + childSession.append('approval/policy', { policy: overrides.approvalPolicy, source: 'delegation' }) + } +} + /** Identity and lineage inputs shared by every in-process child creation. */ export interface ChildCreateInputs { /** The child's reserved session id. */ diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 3425a12851..743d6d63de 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -32,11 +32,14 @@ import type { ToolRestriction } from '@deepseek-ai/dsh-tools' import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' import type { SubagentDescriptorData } from './descriptor.ts' import { + appendDelegatedPolicyOverrides, applyChildComposition, + captureDelegatedPolicyOverrides, childSessionMeta, resolveChildAgentOptions, resolveChildDepth, } from './child-agent.ts' +import type { DelegatedPolicyOverrides } from './child-agent.ts' import { assertSubagentMaxDepth } from './depth.ts' import { seedDescriptorTurn } from './descriptor-seed.ts' import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts' @@ -203,8 +206,17 @@ interface MaterializeInputs { childId: SessionId provider: string parent: Agent - /** Creation inputs; absent for a cold resume, which loads the persisted session. */ - create?: { seed: readonly SessionEvent[]; meta: NonNullable } + /** + * Creation inputs; absent for a cold resume, which loads the persisted + * session — including the delegation policy events a fresh creation seeded, + * so a resume never re-captures the parent's policy. + */ + create?: { + seed: readonly SessionEvent[] + meta: NonNullable + /** Parent policy overrides captured at the delegation boundary. */ + inheritedPolicies: DelegatedPolicyOverrides + } agentOptions: AgentOptions composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } signal: AbortSignal @@ -341,6 +353,9 @@ export class SubagentContinuationManager { ...request.persona !== undefined ? { persona: request.persona } : {}, ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {}, }) + // Capture before the first await: a later parent switch belongs to the + // parent's future, not to this child. + const inheritedPolicies = captureDelegatedPolicyOverrides(parent) const prepared = await this.host.prepareContinuable(spec.provider, { sessionId: childId, @@ -357,7 +372,7 @@ export class SubagentContinuationManager { childId, provider: spec.provider, parent, - create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength) }, + create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), inheritedPolicies }, agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, @@ -878,18 +893,23 @@ export class SubagentContinuationManager { inputs: MaterializeInputs, parentLineage: readonly Agent[], ): Promise { - const { childId, provider, parent } = inputs + const { childId, provider, parent, create } = inputs // No id pre-check here: the child lock serializes each durable child, both // callers reach this only after confirming no Activation exists, and // `AgentRegistry.enter()` is the authoritative collision boundary for an id // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() const setup = (childCtx: Context): AgentSetupCommit => { + // Only fresh creation seeds captured parent policy onto the child's own + // log (after any fork seed, so fresh policy wins stale seed state); a + // cold resume replays those persisted events instead. + if (create !== undefined) { + appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, create.inheritedPolicies) + } applyChildComposition(childCtx, inputs.composition) return this.setupRegistry.apply(childCtx) } const observer = this.host.observeActivation(provider, childId, parent) - const { create } = inputs // Agent creation owns rollback before handle transfer. A rejection leaves // no resident Activation and therefore publishes no lifecycle edge. const handle: AgentHandle = create === undefined diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index eddcf63c3c..75bcf1f12c 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -100,13 +100,15 @@ export { SubagentError } from './error.ts' export { settleRun } from './run-settlement.ts' export { assertSubagentMaxDepth, delegationDepthOf } from './depth.ts' export { + appendDelegatedPolicyOverrides, applyChildComposition, + captureDelegatedPolicyOverrides, childSessionMeta, resolveChildAgentOptions, resolveChildDepth, SubagentDepthError, } from './child-agent.ts' -export type { ChildComposition } from './child-agent.ts' +export type { ChildComposition, DelegatedPolicyOverrides } from './child-agent.ts' export type { ContinuableStart, ContinuableStartSpec, diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts new file mode 100644 index 0000000000..246bafa1ed --- /dev/null +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -0,0 +1,171 @@ +/** + * Continuable-child policy inheritance: a fresh continuable start seeds the + * parent's explicit sandbox/approval overrides onto the child's own log as + * `source: 'delegation'` events, and a cold resume replays that persisted + * snapshot instead of re-capturing the parent (the one-shot + * `subagent-inprocess/tests/inheritance.spec.ts` counterpart). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SandboxPolicyService, { effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' +import ApprovalService, { effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import SubagentService from '../src/index.ts' + +type Script = ConstructorParameters[0] + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +/** Boot the continuable stack plus both policy services the manager consumes opportunistically. */ +async function setup(script: Script) { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const root = mkdtempSync(join(tmpdir(), 'dsh-continuation-inherit-')) + roots.push(root) + await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: root }) + await ctx.plugin(ApprovalService) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + await ctx.plugin(SubagentFork, { providerName: 'fork' }) + ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + return { ctx, parent } +} + +function startSpec(parent: Agent, provider = 'spawn') { + return { + provider, + label: 'child task', + request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent }, + signal: new AbortController().signal, + } +} + +/** Wait until a child's Activation is gone, i.e. its handle finished disposal. */ +async function waitNoActivation(ctx: Context, childId: SessionId): Promise { + await vi.waitFor(() => { + expect(ctx.agents.get(childId)).toBeUndefined() + }, { timeout: 5_000 }) +} + +function policyEvents(events: readonly SessionEvent[]) { + return events.filter(event => event.type === 'sandbox/mode' || event.type === 'approval/policy') +} + +describe('continuable policy inheritance', () => { + it('seeds parent overrides into a fresh continuable child', async () => { + const { ctx, parent } = await setup([textResponse('child done')]) + setSandboxMode(parent.session, 'danger-full-access') + setApprovalPolicy(parent.session, 'never') + let child: Agent | undefined + ctx.on('agent/created', ({ agent }) => { + if (agent !== parent) child = agent + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + // The delegation events are appended in the creation window, so they are + // already the child's effective policy at inbox acceptance. + if (child === undefined) throw new Error('expected the continuable child to be created') + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access') + expect(ctx.approval.overrideOf(child.session)).toBe('never') + + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(policyEvents(loaded.events)).toMatchObject([ + { type: 'sandbox/mode', data: { mode: 'danger-full-access', source: 'delegation' } }, + { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, + ]) + // Durable: a reload folds the same effective policy. + expect(effectiveSandboxMode(loaded.events)).toBe('danger-full-access') + expect(effectiveApprovalPolicy(loaded.events)).toBe('never') + }) + + it('captures policy at delegation before asynchronous child creation', async () => { + const { ctx, parent } = await setup([textResponse('child done')]) + setSandboxMode(parent.session, 'read-only') + + const starting = ctx.subagents.startContinuable(startSpec(parent)) + // A parent switch after the synchronous capture belongs to the parent's + // future, not to this child. + setSandboxMode(parent.session, 'danger-full-access') + const started = await starting + + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access') + expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + }) + + it('does not freeze deployment defaults into an unswitched child', async () => { + const { ctx, parent } = await setup([textResponse('child done')]) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(policyEvents(loaded.events)).toEqual([]) + }) + + it('cold-resumes on the persisted snapshot without re-capturing the parent', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')]) + setSandboxMode(parent.session, 'read-only') + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + // The parent widens AFTER the child was created; the resumed child keeps + // the delegation-time snapshot from its own log. + setSandboxMode(parent.session, 'danger-full-access') + await ctx.subagents.followup(parent, started.childId, [{ type: 'text', text: 'continue please' }], { + source: { kind: 'user' }, + signal: new AbortController().signal, + }) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ + { data: { mode: 'read-only', source: 'delegation' } }, + ]) + expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + }) + + it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => { + const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('forked child')]) + // The stale mode lands inside the completed turn the fork seed replays. + setSandboxMode(parent.session, 'workspace-write') + parent.followup(createUserMessage({ + content: [{ type: 'text', text: 'parent work' }], + source: { kind: 'user' }, + })) + await parent.whenIdle() + setSandboxMode(parent.session, 'read-only') + + const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.meta.seedLength).toBeGreaterThan(0) + expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ + { data: { mode: 'workspace-write' } }, + { data: { mode: 'read-only', source: 'delegation' } }, + ]) + expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + }) +}) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index c72f2ef68d..46e4e4592d 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -26,6 +26,15 @@ { "path": "../../core/scope" }, + { + "path": "../../interaction/user-approval" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, { "path": "../../session/session-persistence" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b08ae2da5d..d73737cfe4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5887,6 +5887,12 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -5914,6 +5920,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../interaction/user-approval cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis From b1d67a693537bb24f0e95460bbdafd98e6c507e6 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 12:45:05 +0800 Subject: [PATCH 063/229] feat(agent-presets): add Codex and Claude Code subagent tools --- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 8 +- ...2026-08-03-per-session-agent-presets.zh.md | 8 +- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 12 +- ...ude-code-and-codex-subagent-backends.zh.md | 12 +- apps/cli/composition.md | 6 + .../agent-presets/code/agent.cordis.yml | 21 + .../agent-presets/cordis/agent.cordis.yml | 21 + .../editing-cordis-compositions/SKILL.md | 28 + .../agent-presets/standard/agent.cordis.yml | 21 + apps/cli/tests/web-agent-presets.e2e.ts | 124 +++- .../product-subagent-both.cordis.snapshot.yml | 38 ++ .../product-subagent-both.cordis.yml | 27 + ...product-subagent-codex.cordis.snapshot.yml | 29 + .../product-subagent-codex.cordis.yml | 18 + examples/acp-agent/tests/acp.snapshot.ts | 19 + .../product-subagent-both/input.json | 7 + .../product-subagent-both/session.jsonl | 22 + .../stdout.expected.jsonl | 4 + .../tool-schemas.expected.json | 569 ++++++++++++++++++ .../product-subagent-codex/input.json | 7 + .../product-subagent-codex/session.jsonl | 22 + .../stdout.expected.jsonl | 4 + .../system-prompt.expected.md | 22 + .../tool-schemas.expected.json | 548 +++++++++++++++++ packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 9 + packages/bundle/base/package.json | 2 + packages/bundle/base/tests/base.spec.ts | 11 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 11 +- .../subagent-claude-code/README.zh.md | 11 +- .../subagent-claude-code/src/index.ts | 8 +- .../subagent-claude-code/src/process.ts | 9 +- .../subagent/subagent-claude-code/src/run.ts | 3 + .../tests/real-deepseek.e2e.ts | 3 +- .../tests/real-product.spec.ts | 21 +- .../tests/subagent-claude-code.spec.ts | 32 + .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 3 +- packages/subagent/subagent-codex/README.zh.md | 3 +- pnpm-lock.yaml | 6 + 45 files changed, 1708 insertions(+), 45 deletions(-) create mode 100644 examples/acp-agent/product-subagent-both.cordis.snapshot.yml create mode 100644 examples/acp-agent/product-subagent-both.cordis.yml create mode 100644 examples/acp-agent/product-subagent-codex.cordis.snapshot.yml create mode 100644 examples/acp-agent/product-subagent-codex.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-both/input.json create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-both/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-codex/input.json create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-codex/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index f3d763058b..1f9868917b 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 6f1643c25008c3363cb10adb7fbff7afeea31cbe -2026-08-03-per-session-agent-presets.zh.md: 7afe9ade5c98fadb96384a7e0acd47531c370e0c +2026-08-03-per-session-agent-presets.md: ca3e6967504ac62b1d79ec28ef9dbd4bf8383bac +2026-08-03-per-session-agent-presets.zh.md: 94651d465939d760554ed8637376ae77cfae6812 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 6f1643c250..ca3e696750 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -18,7 +18,7 @@ Composition splits into two planes, decided by what must be shared rather than b | Plane | Instances | Contents | |---|---|---| -| Host | one | The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), cross-session facilities (persistence, query, projections, storage, settings, credentials, telemetry), and the web host | +| Host | one | The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), cross-session facilities (persistence, query, projections, storage, settings, credentials, telemetry), the subagent providers those facilities resolve, and the web host | | Agent | one per session | What a single agent contributes to those registries: tool plugins, persona and prompt sections, compaction policy | Model routing stays out of presets. `installAgentLlmTarget` is already the per-agent seam for provider, model, and reasoning effort, and an LLM adapter mounted inside a preset would never be resolved by `agent-loop`, which lives in the host plane. @@ -55,7 +55,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Those three are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` and `select` deliberately stay ordinary. The roster carries ids and trust only, and a LAN client's picker needs it; and choosing a preset looked like escalation — one of them mounts the toolset that edits the live runtime — but `session.create` already takes an `agentPreset`, so pinning only the switch would have left the same capability one method over. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. -**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and its backends are host-plane; the preset contributes the delegation TOOLS, which resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. +**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the fixed Codex and Claude Code product providers, are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. **A real-composition test that disables a host row cannot audit that row.** The web composition test disabled `api-gateway` — the api-proxy itself — as a row with side effects, which is exactly the row whose pending injection would have named the break. It now boots with the api-proxy enabled and the browse directory picker substituted, so the boot audit covers the whole host-plane injection graph; only the port, the asset tree, and the telemetry exporter stay off. @@ -78,3 +78,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Make the agent's scope key the preset.** Sessions on one preset would share a layer for free, but per-agent registrations — `installAgentLlmTarget`, per-agent tool restrictions — would then collide across sessions. **Run each preset as a child process.** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) already proves a full child harness works, and isolation would be absolute. It also means proxying streaming, approvals, and projections per session, which is a transport project rather than a composition one. + +**Give product subagents global enable settings and a separate settings page.** The process-wide value would compete with the preset as owner of model-visible tools and could not express two sessions using different compositions. Product providers stay host-side, while ordinary preset rows independently expose Codex and Claude Code tools. + +**Ship one preset for every Codex and Claude Code combination.** Four identities duplicate the full preset composition to represent two independent rows. A copied preset can enable either row directly, so combination presets add roster and maintenance cost without adding a user result. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 7afe9ade5c..94651d4659 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -18,7 +18,7 @@ Status: implemented | 平面 | 实例数 | 内容 | |---|---|---| -| 宿主 | 一份 | 注册表本身(`tools`、`systemPrompt`、`agents`、`agent-loop`、`sessions`)、跨会话设施(持久化、查询、投影、存储、设置、凭据、遥测),以及 web 宿主 | +| 宿主 | 一份 | 注册表本身(`tools`、`systemPrompt`、`agents`、`agent-loop`、`sessions`)、跨会话设施(持久化、查询、投影、存储、设置、凭据、遥测)、这些设施所解析的 subagent provider,以及 web 宿主 | | agent | 每会话一份 | 单个 agent 对这些注册表的贡献:工具插件、人设与提示词段落、压缩策略 | 模型路由不进 preset。`installAgentLlmTarget` 已经是 provider、model 与 reasoning effort 的按 agent 可替换点;而挂在 preset 内部的 LLM 适配器永远不会被 `agent-loop` 解析到,因为后者位于宿主平面。 @@ -56,7 +56,7 @@ Status: implemented **创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这三者被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list` 与 `select` 刻意保持为普通方法。名单只携带 id 与信任级别,而局域网客户端的选择器需要它;至于选择本身,它看起来像提权——其中一个 preset 会挂载可编辑活动运行时的工具集——但 `session.create` 本就接受 `agentPreset`,只固定切换会把同一能力留在隔壁一个方法上。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 -**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与后端属于宿主平面;preset 贡献的是委派**工具**,它们解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 +**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括固定的 Codex 与 Claude Code 产品 provider,都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 **真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试把 `api-gateway`——也就是 api-proxy 本身——当作「有外部副作用的行」禁用了,而它恰恰是那个会以 pending 注入点名此次断裂的行。现在它在启用 api-proxy、并替换为 browse 目录选择器的前提下引导,启动审计因此覆盖整个宿主平面的注入图;只有端口、资源目录与遥测导出器仍然关闭。 @@ -79,3 +79,7 @@ Status: implemented **把 agent 的 scope 键设为 preset。** 同一 preset 上的会话就能免费共享一层,但按 agent 的注册——`installAgentLlmTarget`、按 agent 的工具限制——会跨会话相撞。 **把每个 preset 作为子进程运行。** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) 已经证明完整的子 harness 可行,隔离性也会是绝对的。但这同时意味着要按会话代理流式输出、审批与投影,那是一个传输层项目,而非组装问题。 + +**给产品 subagent 增加全局启用设置与独立设置页。** 进程级值会与 preset 争夺模型可见工具的所有权,也无法表达两个会话使用不同组装。产品 provider 留在宿主,普通 preset 行分别暴露 Codex 与 Claude Code 工具。 + +**为 Codex 与 Claude Code 的每种组合交付一份 preset。** 四个身份会复制完整 preset 组装,只为表示两条独立行。复制后的 preset 已能直接启用任一行,因此组合 preset 只增加名单与维护成本,不增加用户结果。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 7100fc33fc..1d346efd96 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: e81d1fb14f719331c503dba539d6a5ec0f1eed4f -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: aa6d80e38c12a2808a27e93bde8aec5d71e6a509 +2026-08-04-claude-code-and-codex-subagent-backends.md: cf41190c2d03965106e817b50fd2edb806e084bf +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: e0c9cfc96a68fe919a70af64b23ee7ed33ffcea3 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index e81d1fb14f..cf41190c2d 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance ## Decision -The harness publishes two sibling one-shot providers as independently installable, opt-in packages. A user loads a provider and the existing common subagent tool in their own `cordis.yml`: `subagent_codex` binds `codex`, while `subagent_claude_code` binds `claude-code`. The shipped CLI dependency closure and base, Web, and headless configurations load neither provider. Each tool accepts only a standalone text task; product selection and background execution are not model arguments. +The harness publishes two sibling one-shot providers in the shared profile host: `codex` and `claude-code`. Loading the host providers starts no product process. An Agent Preset independently contributes ordinary `dsh-tool-subagent` rows when its agent should see `subagent_codex`, `subagent_claude_code`, both, or neither; the shipped full presets carry both rows disabled so copies have one accurate configuration template without changing the default model schema. Each tool accepts only a standalone text task; product selection and background execution are not model arguments. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools disable background execution and use `maxDepth: 'provider-managed'`, leaving recursion policy with the out-of-process product instead of sending a limit the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. @@ -47,7 +47,7 @@ Codex 0.146.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp ## Claude Code provider -`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. The SDK's platform `optionalDependency` supplies the real Claude Code 2.1.220 CLI. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` command, arguments, cwd, environment, and forwarded signal unchanged to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. +`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. The public configuration contains the same two deployment-owned values as the Codex sibling: an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Each run creates its own `AbortController`, sets `persistSession: false`, and disables `AskUserQuestion`. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK rather than waiting for a user interface the provider does not own. @@ -65,7 +65,7 @@ The Codex evidence pins `@openai/codex@0.146.0` and `codex-cli 0.146.0`. Its rea The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220 and its platform-distributed Claude Code 2.1.220 CLI. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, and whole-tree exit. The Loader e2e resolves both product packages by name while neither product command is available and records zero child starts. +The Claude Code evidence pins Agent SDK 0.3.220 and a native Claude Code installation compatible with its query protocol. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, and whole-tree exit. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. @@ -79,6 +79,10 @@ The project owner's distribution authorization is scoped to the official `@anthr **A model-visible product selector.** Product availability and authentication are deployment facts. Two fixed tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service. +**Global product enable settings and a product-specific Web page.** Those controls make Codex and Claude Code exceptions to the Agent Preset composition that already owns one agent's tool set, and one process-wide choice cannot represent two sessions using different presets. The host always supplies the providers; the preset alone decides which fixed tools its agent receives. + +**One shipped preset per product combination.** Four preset variants encode a two-boolean choice in preset identities and multiply every future standard-preset change. Independent ordinary rows express the same result in the user's copied preset without adding a roster taxonomy. + **Product doubles as required evidence.** Doubles cover exhaustive private protocol branches but do not prove package exports, official distributions, authentication, or real process behavior. Required evidence drives each official product against a loopback model fixture. **Plugin-managed login, product home, models, settings, or permissions.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. The providers expose only an explicit environment overlay and teardown grace; unattended interaction fails closed. @@ -87,7 +91,7 @@ The project owner's distribution authorization is scoped to the official `@anthr ## Consequences -Users can install either or both product providers, bind stable foreground tools in their own Cordis configuration, and delegate one self-contained task through the existing subagent contract. Official product integrations preserve native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence. +Users copy or author an Agent Preset and independently enable either or both stable foreground tools. Every profile host supplies the reusable providers once, while each preset owns only its agent's model-visible tool rows. Official product integrations preserve native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence. Every delegation pays for a fresh product process and independent model context, and only final text reaches the parent. Product-native configuration makes behavior depend on the deployment's installed product, account state, and workspace settings. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index aa6d80e38c..e0c9cfc96a 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -harness 将两个一次性兄弟提供方作为可独立安装、选择启用的包交付。用户在自己的 `cordis.yml` 中加载提供方与现有的通用 subagent 工具:`subagent_codex` 绑定 `codex`,`subagent_claude_code` 绑定 `claude-code`。随产品交付的 CLI(命令行界面)依赖闭包,以及基础、Web 与 headless 配置都不会加载任一提供方。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 +harness 在共享 profile 宿主中交付两个一次性兄弟提供方:`codex` 与 `claude-code`。加载宿主提供方不会启动产品进程。某个 Agent Preset 是否让自己的 agent 看见 `subagent_codex`、`subagent_claude_code`、两者或两者皆无,由该 preset 独立贡献普通的 `dsh-tool-subagent` 行;随附的完整 preset 携带两条默认禁用的行,使复制品拥有一份准确配置模板,同时不改变默认模型 schema。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具会禁用后台执行,并使用 `maxDepth: 'provider-managed'`,将递归策略留给进程外产品,而不是发送提供方无法强制执行的限制。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 @@ -47,7 +47,7 @@ Codex 0.146.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 ## Claude Code 提供方 -`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。SDK 的平台 `optionalDependency` 提供真实的 Claude Code 2.1.220 CLI。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 命令、参数、cwd、环境和转发的信号原样传入 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 +`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 @@ -65,7 +65,7 @@ Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1.220 CLI。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消以及整棵进程树退出。Loader e2e 会在两个产品命令均不可用时按名称解析两个产品包,并记录零次子级启动。 +Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 Claude Code 安装。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消以及整棵进程树退出。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 @@ -79,6 +79,10 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1 **面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 +**全局产品启用设置与产品专属 Web 页面。** 这类控制会让 Codex 与 Claude Code 成为 Agent Preset 组装的例外,而后者本就拥有单个 agent 的工具集;一个进程级选择也无法表达两个会话使用不同 preset。宿主始终提供 provider,只有 preset 决定其 agent 获得哪些固定工具。 + +**为每种产品组合交付一份 preset。** 四个 preset 变体把两个布尔选择编码成 preset 身份,并让未来每次标准 preset 修改都要同步多份副本。用户复制的 preset 中两条独立普通行已经能表达同一结果,无需新增名单分类。 + **以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。 **由插件管理登录、产品主目录、模型、设置或权限。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 @@ -87,7 +91,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1 ## 后果 -用户可以安装任一或两个产品提供方,在自己的 Cordis 配置中绑定稳定的前台工具,并通过现有 subagent 约定委派一项自包含任务。官方产品集成会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 +用户可以复制或创作一个 Agent Preset,并分别启用任一或两个稳定前台工具。每个 profile 宿主只提供一次可复用 provider,而每个 preset 只拥有自己 agent 的模型可见工具行。官方产品集成会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 每次委派都要承担新建产品进程和独立模型上下文的开销,且只有最终文本会到达父级。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index cfcd903e32..71b45bf6cf 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -110,6 +110,10 @@ flowchart LR cfg --> plugin_dsh_base_subagent_spawn plugin_dsh_base_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_dsh_base_subagent_fork + plugin_dsh_base_subagent_codex["subagent-codex
@deepseek-ai/dsh-subagent-codex"] + cfg --> plugin_dsh_base_subagent_codex + plugin_dsh_base_subagent_claude_code["subagent-claude-code
@deepseek-ai/dsh-subagent-claude-code"] + cfg --> plugin_dsh_base_subagent_claude_code plugin_dsh_base_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_dsh_base_tool_subagent_control plugin_dsh_base_tool_subagent_list_agents["tool-subagent-list-agents
@deepseek-ai/dsh-tool-subagent-control/list-agents"] @@ -215,6 +219,8 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `subagent-codex` | `@deepseek-ai/dsh-subagent-codex` | +| `subagent-claude-code` | `@deepseek-ai/dsh-subagent-claude-code` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 65d2716458..481cad47f1 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -197,6 +197,27 @@ toolName: subagent_fork backgroundMode: continuable + # Product providers are host-plane singletons. Copy this preset, then + # remove `disabled` from either ordinary tool row to expose that product + # only to agents composed from the copy. + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed + - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index f2cdeea159..6f08b777a3 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -184,6 +184,27 @@ toolName: subagent_fork backgroundMode: continuable + # Product providers are host-plane singletons. Copy this preset, then + # remove `disabled` from either ordinary tool row to expose that product + # only to agents composed from the copy. + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed + - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index 3810ec334b..7682cf5add 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -26,6 +26,34 @@ A preset is a directory holding one `agent.cordis.yml`, optionally beside a `pre 3. **Rewrite `preset.yml`**: give the copy its own `name` and `description`, and drop any `order` the source declared — that field sorts the shipped roster. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and realm rule above. +### Native product subagents + +Codex and Claude Code providers already live in the host composition. A preset chooses either product by contributing the same ordinary delegation-tool row used for spawn and fork; never move a product provider into the preset and never add a product-specific settings field. + +Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: + +```yaml +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + +- id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed +``` + +The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. The host must provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product. + The shipped preset directories are off-limits: never edit or delete them, and never escalate the sandbox to reach them, even when a change there looks quicker — an upgrade overwrites the install, and corrupting the `cordis` preset disables preset authoring itself. Locally authored presets under the user root are yours to create, edit, and delete. ## The rule that catches people diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 66407faf1d..643d9e65b8 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -196,6 +196,27 @@ toolName: subagent_fork backgroundMode: continuable + # Product providers are host-plane singletons. Copy this preset, then + # remove `disabled` from either ordinary tool row to expose that product + # only to agents composed from the copy. + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed + - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 1bfaed8c67..8f54d6ddf6 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -8,7 +8,7 @@ import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek- import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@cordisjs/plugin-include' -import { beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' import { CallId } from '@deepseek-ai/dsh-llm' @@ -95,6 +95,18 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis const toolNames = (ctx: Context, agent?: Agent): string[] => ctx.tools.schemas(agent).map(schema => schema.name).sort() +function enablePresetTool(composition: string, id: string): string { + const row = ` - id: ${id}\n` + const start = composition.indexOf(row) + if (start < 0) throw new Error(`missing preset row ${id}`) + const end = composition.indexOf('\n - id:', start + row.length) + const disabled = composition.indexOf(' disabled: true\n', start) + if (disabled < 0 || (end >= 0 && disabled > end)) { + throw new Error(`preset row ${id} is not disabled`) + } + return composition.slice(0, disabled) + composition.slice(disabled + ' disabled: true\n'.length) +} + let ctx: Context beforeAll(async () => { const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml') @@ -120,6 +132,24 @@ describe('the shipped Web composition', () => { expect(ctx.agentPresets.defaultId).toBe('standard') }) + it('keeps product providers on the host while shipped presets leave their tools disabled', async () => { + expect(ctx.subagents.list()).toEqual(expect.arrayContaining([ + 'spawn', 'fork', 'codex', 'claude-code', + ])) + + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-products-disabled'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + expect(toolNames(ctx, handle.agent)).not.toEqual(expect.arrayContaining([ + 'subagent_codex', 'subagent_claude_code', + ])) + } finally { + await handle.dispose() + } + }) + it('composes the full agent from `standard`', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-standard'), @@ -355,6 +385,98 @@ describe('the shipped Web composition', () => { }) }) +describe('product subagent rows in user presets', () => { + let productCtx: Context + const ids = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const + + beforeAll(async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-')) + const userRoot = join(root, 'presets') + const settingsFile = join(root, 'settings.yaml') + const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8') + await writeFile(settingsFile, '{}\n') + for (const id of ids) { + let composition = standard + if (id === 'products-codex' || id === 'products-both') { + composition = enablePresetTool(composition, 'tool-subagent-codex') + } + if (id === 'products-claude' || id === 'products-both') { + composition = enablePresetTool(composition, 'tool-subagent-claude-code') + } + const directory = join(userRoot, id) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'agent.cordis.yml'), composition) + } + productCtx = await bootWeb(settingsFile, [{ + id: 'agent-presets', + config: { + default: 'standard', + roots: [ + { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, + { path: userRoot, trust: 'user' }, + ], + }, + }]) + }, 120_000) + + afterAll(async () => { + await productCtx.fiber.dispose() + }) + + it('composes none, either product, or both without changing the shared host registry', async () => { + const expected = new Map([ + ['products-none', []], + ['products-codex', ['subagent_codex']], + ['products-claude', ['subagent_claude_code']], + ['products-both', ['subagent_claude_code', 'subagent_codex']], + ]) + expect(productCtx.subagents.list()).toEqual(expect.arrayContaining([ + 'spawn', 'fork', 'codex', 'claude-code', + ])) + + for (const [id, productTools] of expected) { + const handle = await productCtx.agents.create({ + sessionId: SessionId(`preset-${id}`), + setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined), + }) + try { + const tools = toolNames(productCtx, handle.agent) + expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code')) + .toEqual(productTools) + } finally { + await handle.dispose() + } + } + }) + + it('applies a product-row edit only to later sessions on the preset', async () => { + const preset = await productCtx.agentPresets.resolve('products-none') + const original = await readFile(preset.path, 'utf8') + const existing = await productCtx.agents.create({ + sessionId: SessionId('preset-product-generation-existing'), + setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined), + }) + try { + expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex') + await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-codex')) + + const later = await productCtx.agents.create({ + sessionId: SessionId('preset-product-generation-later'), + setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined), + }) + try { + expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex') + expect(toolNames(productCtx, later.agent)).toContain('subagent_codex') + } finally { + await later.dispose() + } + } finally { + await existing.dispose() + await writeFile(preset.path, original) + } + }) +}) + describe('a switch survives the session', () => { it('records the choice so the log states what the agent runs', async () => { const handle = await ctx.agents.create({ diff --git a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml new file mode 100644 index 0000000000..2863c80641 --- /dev/null +++ b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml @@ -0,0 +1,38 @@ +# Keyless twin of product-subagent-both.cordis.yml: preserve both product +# tools while replacing only the external model adapter. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-both.cordis.yml b/examples/acp-agent/product-subagent-both.cordis.yml new file mode 100644 index 0000000000..7d6269352a --- /dev/null +++ b/examples/acp-agent/product-subagent-both.cordis.yml @@ -0,0 +1,27 @@ +# Add both native product providers and the same independent foreground tool +# rows an Agent Preset may contribute. Loading the composition starts neither +# product; the scenario pins both model-visible schemas. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml new file mode 100644 index 0000000000..74823e5da5 --- /dev/null +++ b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml @@ -0,0 +1,29 @@ +# Keyless twin of product-subagent-codex.cordis.yml: keep the same product +# provider/tool composition and replace only the external model adapter. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-codex.cordis.yml b/examples/acp-agent/product-subagent-codex.cordis.yml new file mode 100644 index 0000000000..169acee9a7 --- /dev/null +++ b/examples/acp-agent/product-subagent-codex.cordis.yml @@ -0,0 +1,18 @@ +# Add the native Codex product provider and its preset-shaped foreground tool to +# the real ACP composition. The model is told not to call it; the scenario pins +# the assembled request schema without starting Codex. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index af044fc58f..b8d67c0c67 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -52,6 +52,8 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) +const PRODUCT_SUBAGENT_CODEX_CONFIG = fileURLToPath(new URL('../product-subagent-codex.cordis.yml', import.meta.url)) +const PRODUCT_SUBAGENT_BOTH_CONFIG = fileURLToPath(new URL('../product-subagent-both.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -122,6 +124,23 @@ const SCENARIOS: Scenario[] = [ // text-turn is the default header pin and owns the prompt and tool-schema // sidecars reused by alternate classes with identical component sequences. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, + { + name: 'product-subagent-codex', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'product-subagent-codex', + configPath: PRODUCT_SUBAGENT_CODEX_CONFIG, + }, + { + name: 'product-subagent-both', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'product-subagent-both', + systemPromptSource: 'product-subagent-codex', + configPath: PRODUCT_SUBAGENT_BOTH_CONFIG, + }, { name: 'session-title-after-turn', hasModelTurn: true, diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/input.json b/examples/acp-agent/tests/snapshots/product-subagent-both/input.json new file mode 100644 index 0000000000..5fe0259a4e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-both/session.jsonl new file mode 100644 index 0000000000..84ac27e70e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/session.jsonl @@ -0,0 +1,22 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"}]}} +{"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Reply with exactly the word:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":30,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"f1418376-f303-4017-acd7-92899c841c8a"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730415298,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730415298,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl new file mode 100644 index 0000000000..acfccdd778 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json new file mode 100644 index 0000000000..76f60e28d4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json @@ -0,0 +1,569 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_claude_code", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_codex", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/input.json b/examples/acp-agent/tests/snapshots/product-subagent-codex/input.json new file mode 100644 index 0000000000..5fe0259a4e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-codex/session.jsonl new file mode 100644 index 0000000000..bd47dfa24f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/session.jsonl @@ -0,0 +1,22 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"}]}} +{"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Reply with exactly the word:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":30,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"c883cf16-01fe-4afc-b37c-d255bb450d21"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730415298,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730415298,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl new file mode 100644 index 0000000000..acfccdd778 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md new file mode 100644 index 0000000000..a6ffe7d4d7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md @@ -0,0 +1,22 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-pro model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json new file mode 100644 index 0000000000..84c4671579 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json @@ -0,0 +1,548 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_codex", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 973959478e..9b4989304d 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: fb003908a262dc21edd3c9d49c972e487534f367 -README.zh.md: 13e64db6d34374fac63bf9bfd60544fc46b86f35 +README.md: aea28a7412fd1123e5bbc593fb7978b2e4595248 +README.zh.md: f11745061c340d23401729ff326d277a0eade428 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index fb003908a2..aea28a7412 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. +The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, repository Plugins, telemetry, and host-level subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Codex and Claude Code providers load dormant; Agent Presets independently decide whether their agent contributes either model-facing delegation tool. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 13e64db6d3..f11745061c 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 +以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、repository 插件、遥测与宿主级 subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。Codex 与 Claude Code provider 以休眠状态加载;Agent Preset 分别决定自己的 agent 是否贡献任一面向模型的委派工具。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index c512127199..3902a2318e 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -288,6 +288,15 @@ config: providerName: fork + # Product providers stay on the host plane because the registry is a + # process singleton. Agent presets decide whether their own model sees the + # matching delegation tools; loading either provider starts no product. + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + # Continuable background children are selected per delegation tool. The # separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index c2cd151b3e..7f237a09cb 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -76,6 +76,8 @@ "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-claude-code": "workspace:^", + "@deepseek-ai/dsh-subagent-codex": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 24ee2a1ba3..4f2c355d8b 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -13,7 +13,10 @@ import { entryListSchema } from '@cordisjs/plugin-include' describe('dsh-base bundle', () => { it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => { const root = fileURLToPath(new URL('..', import.meta.url)) - const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dsh?: { bundle?: { patch?: string } } } + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + dependencies?: Record + dsh?: { bundle?: { patch?: string } } + } expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), { schema: entryListSchema }) expect(Array.isArray(parsed)).toBe(true) @@ -21,5 +24,11 @@ describe('dsh-base bundle', () => { const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(patch => patch.insert ?? []) expect(rows.length).toBeGreaterThan(50) expect(rows.some(row => row.id === 'agent-loop')).toBe(true) + expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(1) + expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(1) + expect(manifest.dependencies).toMatchObject({ + '@deepseek-ai/dsh-subagent-codex': 'workspace:^', + '@deepseek-ai/dsh-subagent-claude-code': 'workspace:^', + }) }) }) diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 23450e6e42..4f62110d49 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md -README.md: 222cf796f71f8dc0dc2c06f7f32bab70ded6ab43 -README.zh.md: 9334820a591cbfcb8dc2046dc3a78201ba193aab +README.md: 5e3138b9211b01de9096fa1b8e8b68321aad0c7d +README.zh.md: cc536d13d58e08efc77f4f7b4374c8a1af8c5caa diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 222cf796f7..5e3138b921 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, starts the SDK-distributed Claude Code CLI through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. +This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, resolves the native `claude` executable through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. ## Start and ownership @@ -29,9 +29,9 @@ The provider advertises no optional start-time capabilities and reports `inherit | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | -Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. +Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. -Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_claude_code` by default. +Shipped profiles load this provider once on the host and start no Claude process until a tool call. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. A custom host composition can still use both rows directly. ```yaml - id: subagent-claude-code @@ -42,6 +42,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: claude-code toolName: subagent_claude_code @@ -51,7 +52,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe ## Product compatibility and evidence -The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose platform optional dependency supplies Claude Code 2.1.220. Required evidence exercises that official distribution through a keyless loopback product path and a credentialed DeepSeek path, while Loader composition proves that both opt-in product packages coexist without starting either product. +The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`. Production runs the native `claude` installation; the SDK's platform optional payload remains in the current installation closure and is tracked as a separate distribution follow-up. Required evidence exercises the compatible native product through a keyless loopback path and a credentialed DeepSeek path, while Loader composition proves that both product packages coexist without starting either product. The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate. @@ -89,7 +90,7 @@ Append-only: the new tool result follows the reusable parent request prefix. - **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode. -- **Product installation and account state remain native** — an incompatible SDK payload, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. +- **Product installation and account state remain native** — a missing or incompatible `claude`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. - **No human interaction path** — `AskUserQuestion` is disabled and other interactive callbacks are absent, so tasks requiring new approval or input fail instead of suspending. - **Final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 9334820a59..cc536d13d5 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务启动 SDK 分发的 Claude Code CLI,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 +本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务解析原生 `claude` 可执行文件,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 ## 启动与所有权 @@ -29,9 +29,9 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 +生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 -请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_claude_code`。 +随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Claude 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。自定义宿主组装仍可直接使用两条配置行。 ```yaml - id: subagent-claude-code @@ -42,6 +42,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: claude-code toolName: subagent_claude_code @@ -51,7 +52,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK ## 产品兼容性与证据 -运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。强制证据会通过无密钥回环产品路径与带密钥 DeepSeek 路径运行该官方发行版,而 Loader 组合则证明两个选择启用的产品包能够共存,且不会启动任一产品。 +运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`。生产运行使用原生 `claude` 安装;SDK 的平台可选载荷仍处于当前安装闭包,并作为独立分发后续项跟踪。强制证据会通过无密钥回环路径与带密钥 DeepSeek 路径运行兼容的原生产品,而 Loader 组合则证明两个产品包能够共存且不会启动任一产品。 项目所有者按身份范围授权分发官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会把其声明条款归类为宽松许可证;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。 @@ -89,7 +90,7 @@ Claude Code 子任务会在一个全新的 SDK query 中接收独立文本任务 - **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 -- **产品安装与账户状态仍由原生机制管理**:不兼容的 SDK 载荷、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 +- **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 - **没有人工交互路径**:`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。 - **仅返回最终文本**:推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index e4d6fbac5f..7552575f45 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -59,19 +59,25 @@ class ClaudeCodeProvider implements SubagentProvider { private readonly config: ResolvedConfig, ) {} - start(request: ResolvedSubagentStartRequest) { + async start(request: ResolvedSubagentStartRequest) { const parentCwd = request.parent.session.header.cwd if (parentCwd === undefined) { throw new Error( 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one', ) } + const executable = await this.ctx.subprocess.resolveExecutable( + 'claude', + this.config.env, + request.signal, + ) const spec: ClaudeCodeRunSpec = { cwd: resolveChildCwd( 'subagent-claude-code', undefined, parentCwd, ), + executable, env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 32a545bf08..6200ac559b 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -6,6 +6,7 @@ */ import { EventEmitter } from 'node:events' +import { extname } from 'node:path' import type { SpawnedProcess, SpawnOptions, @@ -40,17 +41,23 @@ export function sdkEnvironmentOverlay( * Translate one official SDK spawn request to the shared process owner. * @param options - command, arguments, workspace, environment, and forwarded signal from the SDK. * @param graceMs - process-tree termination grace. + * @param platform - host platform selecting the Windows batch-shim boundary. * @returns the fully explicit shared subprocess request. */ export function claudeSpawnSpec( options: SpawnOptions, graceMs: number, + platform: NodeJS.Platform = process.platform, ): SubprocessSpawnSpec { if (options.cwd === undefined || options.cwd.length === 0) { throw new Error('subagent-claude-code: SDK spawn request omitted its workspace') } + const extension = extname(options.command).toLowerCase() + const argv = platform === 'win32' && (extension === '.cmd' || extension === '.bat') + ? ['cmd.exe', '/d', '/s', '/c', options.command, ...options.args] + : [options.command, ...options.args] return { - argv: [options.command, ...options.args], + argv, cwd: options.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs, diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 9dc4d740ac..6c1e0a8dbf 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -44,6 +44,8 @@ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 export interface ClaudeCodeRunSpec { /** Parent Session workspace supplied to the SDK and real CLI. */ readonly cwd: string + /** Exact native Claude Code executable resolved from the host PATH. */ + readonly executable: string /** Explicit deployment/test environment layered after shared scrubbing. */ readonly env: Record /** Subprocess termination grace passed to the shared process-tree owner. */ @@ -180,6 +182,7 @@ export function claudeQueryOptions( return { abortController: controller, cwd: spec.cwd, + pathToClaudeCodeExecutable: spec.executable, env: { ...scrubbedParentEnv(), ...spec.env }, persistSession: false, disallowedTools: ['AskUserQuestion'], diff --git a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts index 806181ad13..7a9a0c61d1 100644 --- a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts @@ -7,7 +7,7 @@ import { rmSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' +import { delimiter, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { Context } from 'cordis' @@ -87,6 +87,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( ]) mkdirSync(directory) const env = { + PATH: `${dirname(claudeBin)}${delimiter}${process.env.PATH ?? ''}`, ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_BASE_URL: `${deepSeekBaseUrl()}/anthropic`, ANTHROPIC_MODEL: 'deepseek-v4-pro[1m]', diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index f76b4038f6..3c73db7a32 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -1,13 +1,15 @@ import { execFile } from 'node:child_process' import { + copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' +import { delimiter, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import type { @@ -19,7 +21,7 @@ import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' -import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as claudeCode from '../src/index.ts' import { @@ -98,9 +100,11 @@ afterEach(async () => { interface RealHarness { readonly ctx: Context readonly handles: SubprocessHandle[] + readonly spawnSpecs: SubprocessSpawnSpec[] readonly parent: Agent readonly workspace: string readonly env: Record + readonly executable: string } async function realHarness(behavior: MessagesBehavior): Promise<{ @@ -112,9 +116,14 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ const workspace = join(root, 'workspace') const claudeConfig = join(root, 'claude-config') const xdgConfig = join(root, 'xdg') + const nativeBin = join(root, 'native-bin') mkdirSync(workspace) mkdirSync(claudeConfig) mkdirSync(xdgConfig) + mkdirSync(nativeBin) + const executable = join(nativeBin, process.platform === 'win32' ? 'claude.exe' : 'claude') + if (process.platform === 'win32') copyFileSync(claudeBin, executable) + else symlinkSync(claudeBin, executable) writeFileSync( join(claudeConfig, 'settings.json'), `${JSON.stringify({ model: settingsModel }, null, 2)}\n`, @@ -122,6 +131,7 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ const fixture = await startMessagesFixture(behavior) fixtures.push(fixture) const env = { + PATH: `${nativeBin}${delimiter}${process.env.PATH ?? ''}`, ANTHROPIC_API_KEY: fakeKey, ANTHROPIC_BASE_URL: fixture.baseUrl, CLAUDE_CONFIG_DIR: claudeConfig, @@ -141,8 +151,10 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ await ctx.plugin(SubagentService) await ctx.plugin(LocalSubprocessService) const handles: SubprocessHandle[] = [] + const spawnSpecs: SubprocessSpawnSpec[] = [] const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + spawnSpecs.push(spec) const handle = spawn(spec) handles.push(handle) return handle @@ -153,7 +165,7 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ session: { header: { cwd: workspace } }, } as unknown as Agent return { - harness: { ctx, handles, parent, workspace, env }, + harness: { ctx, handles, spawnSpecs, parent, workspace, env, executable }, fixture, } } @@ -195,7 +207,7 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { expect(sdkPackage.version).toBe('0.3.220') expect(sdkPackage.claudeCodeVersion).toBe('2.1.220') expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220') - const version = await execFileAsync(claudeBin, ['--version'], { + const version = await execFileAsync(harness.executable, ['--version'], { env: { ...process.env, ...harness.env }, }) expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)') @@ -212,6 +224,7 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { message.type === 'system' && message.subtype === 'init', ) expect(initMessage?.claude_code_version).toBe('2.1.220') + expect(harness.spawnSpecs[0]?.argv[0]).toBe(harness.executable) expect(fixture.requests).toHaveLength(1) const recorded = fixture.requests[0]! diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 8c4ac1708d..423841a9d4 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -248,6 +248,7 @@ function fakeRun( const options: FakeRun['options'] = [] const spec: ClaudeCodeRunSpec = { cwd: '/workspace', + executable: '/native/claude', env: { ANTHROPIC_API_KEY: 'fake-key' }, disposeGraceMs: 5, spawn: (spawnSpec) => { @@ -331,6 +332,8 @@ describe('task admission and package contracts', () => { const child = fakeChild() const spawn = vi.spyOn(ctx.subprocess, 'spawn') .mockImplementation(() => child.handle) + const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable') + .mockResolvedValue('/native/claude') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) await ctx.plugin(claudeCode, { env: { @@ -352,6 +355,11 @@ describe('task admission and package contracts', () => { ) expect(queryMock).not.toHaveBeenCalled() + resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH')) + await expect(ctx.subagents.start('claude-code', request())) + .rejects.toThrow('claude missing from PATH') + expect(queryMock).not.toHaveBeenCalled() + const run = await ctx.subagents.start('claude-code', request()) child.settle({ exitCode: 9, signal: null }) child.stdout.end() @@ -362,6 +370,13 @@ describe('task admission and package contracts', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining( 'subagent-claude-code: child run failed (error):', )) + expect(resolveExecutable).toHaveBeenCalledWith( + 'claude', + expect.objectContaining({ ANTHROPIC_API_KEY: 'provider-fake-key' }), + expect.any(AbortSignal), + ) + expect(queryMock.mock.calls[0]?.[0].options.pathToClaudeCodeExecutable) + .toBe('/native/claude') expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: process.cwd(), graceMs: 29, @@ -441,6 +456,19 @@ describe('official spawn projection', () => { )).toThrow('SDK spawn request omitted its workspace') }) + it.each(['cmd', 'bat'])('routes a Windows .%s shim through cmd.exe', (extension) => { + const command = String.raw`C:\Program Files\Claude\claude.${extension}` + const spec = claudeSpawnSpec(sdkSpawnOptions({ + command, + args: ['--output-format', 'stream-json'], + }), 7, 'win32') + + expect(spec.argv).toEqual([ + 'cmd.exe', '/d', '/s', '/c', command, + '--output-format', 'stream-json', + ]) + }) + it('projects streams, exit facts, listeners, and idempotent tree termination', async () => { const child = fakeChild({ exitOnTerminate: false }) const process = new ManagedClaudeCodeProcess(child.handle) @@ -508,6 +536,7 @@ describe('query options and result mapping', () => { const captured: SubprocessHandle[] = [] const spec: ClaudeCodeRunSpec = { cwd: '/workspace', + executable: '/native/claude', env: { HOST_VISIBLE: 'overridden', ANTHROPIC_API_KEY: 'explicit-fake-key', @@ -523,6 +552,7 @@ describe('query options and result mapping', () => { expect(options).toMatchObject({ abortController: controller, cwd: '/workspace', + pathToClaudeCodeExecutable: '/native/claude', persistSession: false, disallowedTools: ['AskUserQuestion'], }) @@ -670,6 +700,7 @@ describe('run publication, cancellation, and settlement', () => { let index = 0 const spec: ClaudeCodeRunSpec = { cwd: '/workspace', + executable: '/native/claude', env: {}, disposeGraceMs: 5, spawn: () => children[index++]!.handle, @@ -720,6 +751,7 @@ describe('run publication, cancellation, and settlement', () => { request(undefined, parentAbort.signal), { cwd: '/workspace', + executable: '/native/claude', env: {}, disposeGraceMs: 5, spawn: () => child.handle, diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index cbcdb12584..1880fc4359 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md -README.md: c75a98550894f180d0f37e9cdd135b961488f726 -README.zh.md: 0dd3280b363c1a26250aa00cfeba5398114a2e3e +README.md: f10ebe0448b2942e2cad8efecb6be4681cf601a6 +README.zh.md: ef107577afcdc81a64ea46b2e996d46a562d1505 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index c75a985508..f10ebe0448 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -27,7 +27,7 @@ The provider advertises no optional start-time capabilities and reports `inherit Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. -Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_codex` by default. +Shipped profiles load this provider once on the host and start no Codex process until a tool call. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. A custom host composition can still use both rows directly. ```yaml - id: subagent-codex @@ -38,6 +38,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: codex toolName: subagent_codex diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 0dd3280b36..ef107577af 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -27,7 +27,7 @@ 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 -请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_codex`。 +随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Codex 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。自定义宿主组装仍可直接使用两条配置行。 ```yaml - id: subagent-codex @@ -38,6 +38,7 @@ - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: codex toolName: subagent_codex diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea741e98c1..d98cc8c053 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1226,6 +1226,12 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent + '@deepseek-ai/dsh-subagent-claude-code': + specifier: workspace:^ + version: link:../../subagent/subagent-claude-code + '@deepseek-ai/dsh-subagent-codex': + specifier: workspace:^ + version: link:../../subagent/subagent-codex '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../subagent/subagent-fork From 37aab00c24d46b05f78ec3c1d812037624bc90c0 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 12:46:06 +0800 Subject: [PATCH 064/229] cleanup(subagent): remove stale lint suppression --- packages/subagent/subagent-claude-code/src/run.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 6c1e0a8dbf..f4600f4bc9 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -258,7 +258,6 @@ export async function startClaudeCodeRun( ) } } - // oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited. if (cancelledBeforeCleanup || request.signal.aborted) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } From a7fc81d745ad178194be6c905bb1a6b4c19cd624 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 15:13:12 +0800 Subject: [PATCH 065/229] docs: replace front door terminology --- .agents/notes/README.i18n.yaml | 2 +- .agents/notes/README.md | 2 +- ...-15-llm-model-catalog-and-acp-selection.i18n.yaml | 4 ++-- ...2026-07-15-llm-model-catalog-and-acp-selection.md | 8 ++++---- ...6-07-15-llm-model-catalog-and-acp-selection.zh.md | 8 ++++---- ...026-07-19-gui-layering-and-rpc-protocol.i18n.yaml | 4 ++-- .../2026-07-19-gui-layering-and-rpc-protocol.md | 6 +++--- .../2026-07-19-gui-layering-and-rpc-protocol.zh.md | 6 +++--- ...config-tree-boot-and-transport-layering.i18n.yaml | 4 ++-- ...24-web-config-tree-boot-and-transport-layering.md | 8 ++++---- ...web-config-tree-boot-and-transport-layering.zh.md | 8 ++++---- .../2026-08-05-profile-plugin-bundles.i18n.yaml | 4 ++-- .../2026-08-05-profile-plugin-bundles.md | 2 +- .../2026-08-05-profile-plugin-bundles.zh.md | 2 +- ...08-09-headless-direct-core-entry-point.i18n.yaml} | 6 +++--- ...> 2026-08-09-headless-direct-core-entry-point.md} | 12 ++++++------ ...026-08-09-headless-direct-core-entry-point.zh.md} | 8 ++++---- ...026-07-20-error-cause-chain-diagnostics.i18n.yaml | 4 ++-- .../2026-07-20-error-cause-chain-diagnostics.md | 2 +- .../2026-07-20-error-cause-chain-diagnostics.zh.md | 2 +- .../feature/2026-06-24-workspace-context.i18n.yaml | 2 +- .../feature/2026-06-24-workspace-context.md | 2 +- .../feature/2026-07-16-harness-level-loop.i18n.yaml | 4 ++-- .../feature/2026-07-16-harness-level-loop.md | 2 +- .../feature/2026-07-16-harness-level-loop.zh.md | 2 +- .../2026-07-16-persistent-pty-sessions.i18n.yaml | 4 ++-- .../feature/2026-07-16-persistent-pty-sessions.md | 2 +- .../feature/2026-07-16-persistent-pty-sessions.zh.md | 2 +- .../feature/2026-07-19-human-goal-command.i18n.yaml | 4 ++-- .../feature/2026-07-19-human-goal-command.md | 2 +- .../feature/2026-07-19-human-goal-command.zh.md | 2 +- .../2026-07-19-plugin-command-registration.i18n.yaml | 4 ++-- .../2026-07-19-plugin-command-registration.md | 6 +++--- .../2026-07-19-plugin-command-registration.zh.md | 6 +++--- .../2026-07-21-local-instruction-overlay.i18n.yaml | 2 +- .../feature/2026-07-21-local-instruction-overlay.md | 2 +- .../2026-07-24-web-session-model-selector.i18n.yaml | 4 ++-- .../feature/2026-07-24-web-session-model-selector.md | 2 +- .../2026-07-24-web-session-model-selector.zh.md | 4 ++-- ...-08-07-default-model-follows-the-picker.i18n.yaml | 4 ++-- .../2026-08-07-default-model-follows-the-picker.md | 4 ++-- ...2026-08-07-default-model-follows-the-picker.zh.md | 4 ++-- .../2026-08-08-dsh-run-headless-command.i18n.yaml | 4 ++-- .../feature/2026-08-08-dsh-run-headless-command.md | 2 +- .../2026-08-08-dsh-run-headless-command.zh.md | 2 +- ...26-08-08-user-explicit-skill-invocation.i18n.yaml | 4 ++-- .../2026-08-08-user-explicit-skill-invocation.md | 4 ++-- .../2026-08-08-user-explicit-skill-invocation.zh.md | 4 ++-- .../2026-07-05-uniform-agent-note-format.i18n.yaml | 2 +- .../process/2026-07-05-uniform-agent-note-format.md | 2 +- ...07-19-remove-generated-agent-note-index.i18n.yaml | 2 +- .../2026-07-19-remove-generated-agent-note-index.md | 2 +- .../2026-07-22-product-first-root-readme.i18n.yaml | 2 +- .../process/2026-07-22-product-first-root-readme.md | 4 ++-- ...-08-03-package-anchored-subsystem-pages.i18n.yaml | 4 ++-- .../2026-08-03-package-anchored-subsystem-pages.md | 2 +- ...2026-08-03-package-anchored-subsystem-pages.zh.md | 2 +- ...2026-07-23-acp-automation-only-protocol.i18n.yaml | 2 +- .../2026-07-23-acp-automation-only-protocol.md | 2 +- .../2026-08-04-remove-tui-package.i18n.yaml | 2 +- .../simplification/2026-08-04-remove-tui-package.md | 4 ++-- .../2026-08-08-remove-cli-demo.i18n.yaml | 2 +- .../simplification/2026-08-08-remove-cli-demo.md | 2 +- apps/cli/src/profile-boot.ts | 2 +- docs/architecture.i18n.yaml | 2 +- docs/architecture.md | 4 ++-- docs/capability-seams.i18n.yaml | 4 ++-- docs/capability-seams.md | 4 ++-- docs/capability-seams.zh.md | 4 ++-- docs/cordis-tutorial/07-into-the-harness.i18n.yaml | 4 ++-- docs/cordis-tutorial/07-into-the-harness.md | 2 +- docs/cordis-tutorial/07-into-the-harness.zh.md | 2 +- docs/subsystems/core.i18n.yaml | 4 ++-- docs/subsystems/core.md | 2 +- docs/subsystems/core.zh.md | 2 +- docs/user/guide/providers.i18n.yaml | 4 ++-- docs/user/guide/providers.md | 2 +- docs/user/guide/providers.zh.md | 2 +- examples/acp-agent/composition.md | 2 +- examples/headless-agent/README.i18n.yaml | 2 +- examples/headless-agent/README.md | 2 +- packages/bundle/base/cordis.patch.yml | 2 +- packages/client/ui-skill/README.i18n.yaml | 4 ++-- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-skill/src/client/index.ts | 4 ++-- packages/core/README.i18n.yaml | 2 +- packages/core/README.md | 4 ++-- packages/core/agent-default-model/README.i18n.yaml | 2 +- packages/core/agent-default-model/README.md | 6 +++--- packages/core/agent-default-model/package.json | 2 +- packages/core/agent-default-model/src/index.ts | 2 +- packages/core/agent/src/model-selection.ts | 4 ++-- packages/examples/README.i18n.yaml | 4 ++-- packages/examples/README.md | 6 +++--- packages/examples/README.zh.md | 4 ++-- packages/examples/acp-demo/README.i18n.yaml | 4 ++-- packages/examples/acp-demo/README.md | 2 +- packages/examples/acp-demo/README.zh.md | 2 +- packages/examples/acp-demo/src/index.ts | 4 ++-- packages/examples/agent-spine-demo/README.i18n.yaml | 4 ++-- packages/examples/agent-spine-demo/README.md | 12 ++++++------ packages/examples/agent-spine-demo/README.zh.md | 12 ++++++------ packages/examples/agent-spine-demo/src/index.ts | 2 +- .../agent-spine-demo/tests/agent-core.spec.ts | 6 +++--- packages/feedback/command-feedback/README.i18n.yaml | 2 +- packages/feedback/command-feedback/README.md | 2 +- packages/hooks/hooks-claude/src/index.ts | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 ++-- packages/host/apiproxy/README.md | 4 ++-- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/index.ts | 2 +- packages/plan/plan-mode/README.i18n.yaml | 2 +- packages/plan/plan-mode/README.md | 2 +- packages/scaffold/helper/src/features/builtin/app.ts | 6 +++--- packages/scaffold/helper/src/features/feature.ts | 4 ++-- packages/scaffold/helper/src/project/types.ts | 2 +- packages/scaffold/helper/tests/project.spec.ts | 2 +- .../self-modification/tool-cordis/src/api-catalog.ts | 2 +- .../self-modification/tool-cordis/src/sandbox.ts | 2 +- scripts/gen-doc-graphs.ts | 6 +++--- 121 files changed, 211 insertions(+), 211 deletions(-) rename .agents/notes/implemented/architecture/{2026-08-09-headless-direct-core-front-door.i18n.yaml => 2026-08-09-headless-direct-core-entry-point.i18n.yaml} (55%) rename .agents/notes/implemented/architecture/{2026-08-09-headless-direct-core-front-door.md => 2026-08-09-headless-direct-core-entry-point.md} (76%) rename .agents/notes/implemented/architecture/{2026-08-09-headless-direct-core-front-door.zh.md => 2026-08-09-headless-direct-core-entry-point.zh.md} (94%) diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index b33a36665d..56eeb36b4d 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/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 .agents/notes/README.md -README.md: 3cfbb5154713046846a3bfcb2ccea62c0e4cb6c0 +README.md: d3a8943a78238d974d54028e38b773e932429b0e README.zh.md: 4b3a1ee57ea61a8e8ba4d01cf7c719bbf8440e30 diff --git a/.agents/notes/README.md b/.agents/notes/README.md index 3cfbb51547..d3a8943a78 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -One kind of design doc lives here. An **Agent Note** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. This file is the front door and contract: where Agent Notes live, when to write one, and [the in-file format](#the-file-format). +One kind of design doc lives here. An **Agent Note** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. This file is the entry point and contract: where Agent Notes live, when to write one, and [the in-file format](#the-file-format). ## Layout and naming diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml index 02d531f642..0c81565bdd 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-15-llm-model-catalog-and-acp-selection.md -2026-07-15-llm-model-catalog-and-acp-selection.md: 77f8e379e2b07ecf4e67fa7197752543cfedd6dd -2026-07-15-llm-model-catalog-and-acp-selection.zh.md: ce4a04a66e345834bc2b16b743be89dc7a0b9424 +2026-07-15-llm-model-catalog-and-acp-selection.md: bfd17c73b01319c10d5dc03333b3c726db5d6f33 +2026-07-15-llm-model-catalog-and-acp-selection.zh.md: aeddada5591bb2da2c0861acc368516eff148172 diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md index 77f8e379e2..bfd17c73b0 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md @@ -26,17 +26,17 @@ Catalog membership is advisory. It drives selectors and diagnostics but never ch `dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` named `DeepSeek-V4-Flash` and `deepseek-v4-pro` named `DeepSeek-V4-Pro`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged. -### Per-session selection in the front door +### Per-session selection in the front end -A selection is owned by the front door that offers it (today the TUI `/model` selector), never by `LlmService` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes. +A selection is owned by the front end that offers it (today the TUI `/model` selector), never by `LlmService` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes. The ACP automation transport is not a catalog consumer. Its deployment config supplies one optional provider/model target for newly created agents, and it advertises no model selector or configuration-option interface. ### Prompt/request consistency and durability -`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-door-owned selection. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched. +`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-end-owned selection. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched. -The request header remains the durable source of truth. When a selection is actually used, the existing full `request/header` snapshot records it, and a front door initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state. +The request header remains the durable source of truth. When a selection is actually used, the existing full `request/header` snapshot records it, and a front end initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md index ce4a04a66e..aeddada559 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md @@ -26,17 +26,17 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多 `dsh-llm-pi-ai` 将已配置提供方的安装目录 `getModels(provider)` 映射为中立目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受可选的 `models` 配置作为展示条目,默认包含名为 `DeepSeek-V4-Flash` 的 `deepseek-v4-flash` 和名为 `DeepSeek-V4-Pro` 的 `deepseek-v4-pro`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。 -### 前门内的会话级选择 +### 前端内的会话级选择 -选择由提供它的前门拥有(今天是 TUI 的 `/model` 选择器),而不由 `LlmService` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。 +选择由提供它的前端拥有(今天是 TUI 的 `/model` 选择器),而不由 `LlmService` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。 ACP 自动化传输层不是目录消费方。它通过部署配置为新创建的 agent 提供一个可选的提供方/模型目标,不展示模型选择器或配置选项接口。 ### 提示词/请求一致性与持久化 -`installModelSelection`(位于 `dsh-agent`)为前门拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。 +`installModelSelection`(位于 `dsh-agent`)为前端拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。 -请求头仍是持久化的真源。当某个选择真正被使用时,现有的完整 `request/header` 快照会记录它;前门先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。 +请求头仍是持久化的真源。当某个选择真正被使用时,现有的完整 `request/header` 快照会记录它;前端先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index 87c65807b8..d47c50cdb7 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 7997a682c8745f7b3d0a9721acfa9355603f9b2e -2026-07-19-gui-layering-and-rpc-protocol.zh.md: cb36b5cb725128e1c5067e5e69e52aa851668e52 +2026-07-19-gui-layering-and-rpc-protocol.md: 705b0df5feb5fedaae4d198aed71758b54586e93 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: b28b08b4b9da058e01af62e610d4e226d794151f diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 7997a682c8..705b0df5fe 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -31,7 +31,7 @@ Directories layer as follows: - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session front door](2026-08-09-headless-direct-core-front-door.md), with zero Host, HTTP, or browser layer. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. - A future Electron shape reuses the same web client packages over an IPC fetch carrier. ``` @@ -79,7 +79,7 @@ Packages under `packages/host/*` and `packages/client/*` **must carry the direct 2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the shape's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app. 3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports. -The two existing shapes preserve the boundary: the Web shape mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem, mount via `ctx.plugin(front-door plugin)` directly, and wear no fetch. +The two existing shapes preserve the boundary: the Web shape mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem, mount via `ctx.plugin(entry-point plugin)` directly, and wear no fetch. ## Message protocol @@ -242,7 +242,7 @@ Every client shape consumes one contract: adding a unary method is a five-step m |---|---| | Packaging by "product shape" (a web family, an electron family) | What shapes share is host/client capability, not the shape itself; capability-provider layering means a new shape needs zero new packages | | A package per mixture (e.g. a standalone headless package) | A mixture has exactly one consumer (its own app); packaging it is ownerless abstraction, while assembly in the app is readable and disposable | -| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Client shapes require wire validation, observability, and multi-client consistency. Direct headless is a local front door with no client boundary and uses the public Agent/Session seams rather than a client command plane | +| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Client shapes require wire validation, observability, and multi-client consistency. Direct headless is a local entry point with no client boundary and uses the public Agent/Session seams rather than a client command plane | | webserver depending on runtime (saving the handler injection) | Structural-typing injection keeps webserver reusable by sidecars/tests with zero workspace deps; a package dependency would drag assembly knowledge into the carrier layer | | Package names without the group prefix (continuing dsh-) | `dsh-runtime`/`dsh-web-ui` lose their belonging in the flat npm namespace; the cost is one explicit paths entry per package | | Reusing the in-repo JSON-RPC 2.0 (dsh-jsonrpc) | Numeric error codes degrade to a single fallback code, contracts get aligned by hand in two copies, and naming drifts without a convention | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index cb36b5cb72..b28b08b4b9 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -29,7 +29,7 @@ Status: implemented - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的前门](2026-08-09-headless-direct-core-front-door.md),不含 Host、HTTP 或浏览器层。 + - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 - 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。 ``` @@ -77,7 +77,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. 2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该形态私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。 3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。 -现有两种形态保持这一边界:Web 形态挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(前门插件)` 挂载,不套 fetch。 +现有两种形态保持这一边界:Web 形态挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不套 fetch。 ## 消息协议 @@ -240,7 +240,7 @@ export type ResponseValue = |---|---| | 按「产品形态」分包(web 一族、electron 一族) | 形态间共享的是 host/client 两侧能力而非形态本身;能力支持方分层让新形态零新包 | | 混合体建包(如 headless 独立包) | 混合体只有一个消费者(它自己的 app),建包是无主抽象;拼装写在 app 里可读可弃 | -| 消费型 client 直连 ctx(省 apiproxy 一层) | client 形态需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地前门,使用公开的 Agent/Session seam,而不是 client 命令面 | +| 消费型 client 直连 ctx(省 apiproxy 一层) | client 形态需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地入口,使用公开的 Agent/Session seam,而不是 client 命令面 | | webserver 依赖 runtime(省 handler 注入) | 结构 typing 注入让 webserver 可被 sidecar/测试复用且零 workspace 依赖;包依赖会把装配知识拖进承载层 | | 包名不带组前缀(沿用 dsh-<尾段>) | `dsh-runtime`/`dsh-web-ui` 在扁平 npm 命名空间里失去归属信息;代价只是每包一条显式 paths | | 复用仓内 JSON-RPC 2.0(dsh-jsonrpc) | 数字错误码退化成单码兜底、约定双份人肉对齐、命名无 convention 自然漂移 | diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index ff517555db..e7fd7799f0 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 496499a691dbca012e5e953cbb6eb1d0bf25b635 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: c4bed7b730cf37e1d90750f5c93fbccb920ced21 +2026-07-24-web-config-tree-boot-and-transport-layering.md: 92ec665acc745e61f656bd0e57454ad266b722f9 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7cd96ad9e52c19a005e6bff356dc5159ad3a31bc diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 496499a691..92ec665acc 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -16,23 +16,23 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. -**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless front door](2026-08-09-headless-direct-core-front-door.md) and the Web gateway consume the same state. +**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state. -**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{workspaceRoot?}`, consumes the base layer's front-door-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. +**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{workspaceRoot?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`. ## Consequences - Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted. -- [Headless is a direct core front door](2026-08-09-headless-direct-core-front-door.md): its shipped profile contains the shared base Agent capabilities and omits Host, HTTP, Web, and browser layers. The transport split in this note is the browser surface's contract. +- [Headless is a direct core entry point](2026-08-09-headless-direct-core-entry-point.md): its shipped profile contains the shared base Agent capabilities and omits Host, HTTP, Web, and browser layers. The transport split in this note is the browser surface's contract. - A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`. ## Alternatives considered | Rejected | One-line reason | |---|---| -| Dedicated `dsh-host-profile` receiver package | User model state belongs to the Settings-backed `ctx.agentDefaultModel`; an extra Host receiver would duplicate ownership and exclude direct front doors | +| Dedicated `dsh-host-profile` receiver package | User model state belongs to the Settings-backed `ctx.agentDefaultModel`; an extra Host receiver would duplicate ownership and exclude direct entry points | | Runtime `assembly` shim plugin providing an `apiHandler` service | Existed only because `createApiProxy` lived in runtime; moving it into apiproxy made the gateway self-hosting, and `toFetchHandler` is a pure function the binding side calls | | Full-rescan + incremental scan coexisting | Two implementations, two semantics; the single per-package path covers the activation pass too | | A bespoke `./impl` export on the modules package | Non-uniform export surface; the standard `./client` carries the whole browser half | diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index c4bed7b730..7cd96ad9e5 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -16,23 +16,23 @@ Status: implemented **boot 胶水由两个类组成。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。 -**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 前门](2026-08-09-headless-direct-core-front-door.md)与 Web 网关消费同一份状态。 +**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.md)与 Web 网关消费同一份状态。 -**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{workspaceRoot?}`,消费 base 层不偏向特定前门的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 +**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{workspaceRoot?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。 ## 后果 - 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins`、`CLIENT_PACKAGES`、`createHostWebPluginRegistry`、`startWebServer`、webserver 的图/SSE/api 知识)全部删除。 -- [Headless 是直接 core 前门](2026-08-09-headless-direct-core-front-door.md):其随附 profile 包含共享的 base Agent 能力,并省去 Host、HTTP、Web 与浏览器层。本笔记的传输划分是浏览器 surface 的约定。 +- [Headless 是直接 core 入口](2026-08-09-headless-direct-core-entry-point.md):其随附 profile 包含共享的 base Agent 能力,并省去 Host、HTTP、Web 与浏览器层。本笔记的传输划分是浏览器 surface 的约定。 - 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。 ## 考虑过的替代方案 | 弃案 | 一行理由 | |---|---| -| 专门的 `dsh-host-profile` 受体包 | 用户模型状态归 Settings 支撑的 `ctx.agentDefaultModel` 所有;额外的 Host 受体会重复归属,并排除直接前门 | +| 专门的 `dsh-host-profile` 受体包 | 用户模型状态归 Settings 支撑的 `ctx.agentDefaultModel` 所有;额外的 Host 受体会重复归属,并排除直接入口 | | 运行时里的 `assembly` 垫层插件(provide `apiHandler`) | 它的存在只因 `createApiProxy` 住运行时;本体迁入 apiproxy 后网关自持插件身份,且 `toFetchHandler` 是绑定方自己调的纯函数 | | 全量重扫与增量扫描并存 | 两条实现两份语义;单包路径足以覆盖激活初扫 | | modules 包特设 `./impl` 出口 | 出口面不统一;标准 `./client` 承载完整浏览器半 | diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index fcac71abf4..938e802716 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 8b5ab0c99282f6868fc3f70781e618af9a317c09 -2026-08-05-profile-plugin-bundles.zh.md: 2a685d68b3de9210488e26f8e6dd93dfc07f956c +2026-08-05-profile-plugin-bundles.md: 2924b3cb445064fd47d82bcc94ec8d77ded5721b +2026-08-05-profile-plugin-bundles.zh.md: b2287034010bcac1048bb385b2266f1bc75921da diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 8b5ab0c992..2924b3cb44 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. -The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). `dsh web` is the Web-flag alias for `--profile web`; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile; generic `dsh --profile ` boots without a task. Patch overlays use `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core front door](2026-08-09-headless-direct-core-front-door.md) owns the headless composition contract. +The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). `dsh web` is the Web-flag alias for `--profile web`; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile; generic `dsh --profile ` boots without a task. Patch overlays use `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. The [`dsh run` command decision](../feature/2026-08-08-dsh-run-headless-command.md) owns the one-shot grammar; this note owns the profile composition it selects. diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index 2a685d68b3..b228703401 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -12,7 +12,7 @@ Status: implemented 一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 -随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。`dsh web` 是携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile;通用的 `dsh --profile ` 启动 profile 而不携带任务。patch overlay 使用 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 前门](2026-08-09-headless-direct-core-front-door.md)负责 headless 组合约定。 +随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。`dsh web` 是携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile;通用的 `dsh --profile ` 启动 profile 而不携带任务。patch overlay 使用 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 [`dsh run` 命令决策](../feature/2026-08-08-dsh-run-headless-command.md)负责一次性语法;本 Agent Note 负责该语法所选择的 profile 组合。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml similarity index 55% rename from .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml index 20b200713a..b851627050 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md -2026-08-09-headless-direct-core-front-door.md: f4604329a9276448a0021bb749b09e8c1b82e3c1 -2026-08-09-headless-direct-core-front-door.zh.md: aaa1289894bf3c69b39aa863493dffdc3437ad01 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md +2026-08-09-headless-direct-core-entry-point.md: 49afe2993de7302adbedcdf9e8e2347d6424ee2a +2026-08-09-headless-direct-core-entry-point.zh.md: 73c1cbe5ac777025f63f46751b1d5ccebbfe9676 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md similarity index 76% rename from .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md rename to .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md index f4604329a9..49afe2993d 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md @@ -1,22 +1,22 @@ -# Agent Note: headless is a direct core front door +# Agent Note: headless is a direct core entry point Status: implemented -English | [中文](2026-08-09-headless-direct-core-front-door.zh.md) +English | [中文](2026-08-09-headless-direct-core-entry-point.zh.md) ## Problem The `headless` product contract is one local task with final assistant text on stdout, a success-sensitive exit code, empty stderr on success, and no listening port. A composition containing Workspace Host services, ApiProxy, HTTP, the Web runtime, or browser plugins contradicts that contract and makes local completion depend on an unrelated transport tree. -The direct front door still needs the same deployment model state as Web-created Agents. A separate provider/model default would give one deployment two answers, while deriving completion before the Agent and Session persistence are quiescent permits stdout and the exit code to observe incomplete state. +The direct entry point still needs the same deployment model state as Web-created Agents. A separate provider/model default would give one deployment two answers, while deriving completion before the Agent and Session persistence are quiescent permits stdout and the exit code to observe incomplete state. ## Decision The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The headless bundle supplies its persona and tool mode, disables HMR, mounts the Code Mode worker explicitly, and inserts `headless-runner`. Its tree contains no `@deepseek-ai/dsh-host-*` package, ApiProxy, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation. -`headless-runner` is a direct core front door. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. A terminal `error` reason writes its durable code and message to stderr; unexpected driver failures also use stderr and exit 1. +`headless-runner` is a direct core entry point. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. A terminal `error` reason writes its durable code and message to stderr; unexpected driver failures also use stderr and exit 1. -`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct and ApiProxy front doors consume this service; ApiProxy alone owns session-local precedence, model validation, and persistence of accepted Web selections. +`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct and ApiProxy entry points consume this service; ApiProxy alone owns session-local precedence, model validation, and persistence of accepted Web selections. `loadProfile` recognizes the exact installation-owned headless tuple (`dsh-base`, `dsh-web-app`, `dsh-headless`) and normalizes it to the shipped headless template while preserving every other manifest field. Extra, missing, or reordered bundle lists are user-owned and remain untouched. @@ -31,7 +31,7 @@ Package tests use the real Session store and Agent registry around a scripted Ag | Alternative | Contract mismatch | |---|---| | Keep `dsh-web-app` but suppress its observation line | The process still opens a port and carries the Host, Web, and browser trees. | -| Build a Host-only one-shot bundle around ApiProxy | ApiProxy is a client protocol gateway; a local one-shot front door has no client boundary. | +| Build a Host-only one-shot bundle around ApiProxy | ApiProxy is a client protocol gateway; a local one-shot entry point has no client boundary. | | Use `InProcessApiClient` for product-level protocol coverage | Product execution would depend on an unrelated protocol solely to exercise that protocol. | | Give headless a separate provider/model config | Direct and Web creation would have independent defaults and persistence. | | Omit Code Mode and Session persistence | Both capabilities belong to one-shot Agent execution rather than Web presentation. | diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md similarity index 94% rename from .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md rename to .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md index aaa1289894..73c1cbe5ac 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md @@ -2,13 +2,13 @@ Status: implemented -[English](2026-08-09-headless-direct-core-front-door.md) | 中文 +[English](2026-08-09-headless-direct-core-entry-point.md) | 中文 ## 问题 `headless` 的产品约定是一个本地任务:最终 assistant 文本写入 stdout,退出状态反映成功与否,成功时 stderr 为空,并且不打开监听端口。包含 Workspace Host 服务、ApiProxy、HTTP、Web 运行时或浏览器插件的组合违背这一约定,也使本地完成状态依赖无关的传输树。 -直接前门仍需要与 Web 所创建 Agent 相同的部署模型状态。独立的提供方/模型默认值会让同一部署产生两种答案,而在 Agent 与会话持久化完全停稳之前推导完成状态,会让 stdout 与退出状态观察到不完整状态。 +直接入口仍需要与 Web 所创建 Agent 相同的部署模型状态。独立的提供方/模型默认值会让同一部署产生两种答案,而在 Agent 与会话持久化完全停稳之前推导完成状态,会让 stdout 与退出状态观察到不完整状态。 ## 决策 @@ -16,7 +16,7 @@ Status: implemented `headless-runner` 是直接使用核心服务的入口。Loader 完全加载后,它读取 `ctx.agentDefaultModel.currentSelection()`,通过 `ctx.agents.create` 创建一个新的持久化 Agent,在 Agent 作用域中安装该 `ModelSelection`,等待启动工作完全停稳,锚定会话事件序号,提交一条普通用户消息,再次等待完全停稳。随后,它等待 `ctx.sessions.flush`,折叠自身持有的持久事件区间,以取得最后一条非空 assistant 文本和最终 `turn/end` 结束原因,将文本连同一个换行写入 stdout,并且仅在结束原因为 `completed` 时请求启动器以退出状态 0 有界关闭。结束原因为 `error` 时,其持久化错误码与消息写入 stderr;驱动器的意外失败也写入 stderr 并以 1 退出。 -`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接前门与 ApiProxy 前门均消费该服务;只有 ApiProxy 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 +`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接入口与 ApiProxy 入口均消费该服务;只有 ApiProxy 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 `loadProfile` 识别安装过程拥有的精确 headless 元组(`dsh-base`、`dsh-web-app`、`dsh-headless`),将其规范化为随附的 headless 模板,并保留 manifest(元数据清单)的其他所有字段。带额外项、缺少项或顺序不同的组合包列表归用户所有,保持不变。 @@ -31,7 +31,7 @@ Status: implemented | 替代方案 | 约定不匹配之处 | |---|---| | 保留 `dsh-web-app`,但隐藏观察行 | 进程仍会打开端口并携带 Host、Web 与浏览器插件树。 | -| 围绕 ApiProxy 构建纯 Host 一次性组合包 | ApiProxy 是客户端协议网关,而本地一次性前门没有客户端边界。 | +| 围绕 ApiProxy 构建纯 Host 一次性组合包 | ApiProxy 是客户端协议网关,而本地一次性入口没有客户端边界。 | | 使用 `InProcessApiClient` 实现产品级协议覆盖 | 产品执行会仅为测试无关协议而依赖该协议。 | | 为 headless 单独提供提供方/模型配置 | 直接创建与 Web 创建会拥有彼此独立的默认值和持久化。 | | 省略 Code Mode 与会话持久化 | 两项能力都属于一次性 Agent 执行,而不是 Web 呈现。 | diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml index c9581fcbfb..9d94121626 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md -2026-07-20-error-cause-chain-diagnostics.md: 32716b5a68b3b73bded47633eca95995cdbfc586 -2026-07-20-error-cause-chain-diagnostics.zh.md: 9911c32b6d68c1f5569a1fceadb65e23c6586594 +2026-07-20-error-cause-chain-diagnostics.md: b80dd08d79a57738a6eef2f8638b0336ca80d4bf +2026-07-20-error-cause-chain-diagnostics.zh.md: 914e983d7ea81eef8bca8fd5aa3791f2f44d4080 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md index 32716b5a68..b80dd08d79 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md @@ -9,7 +9,7 @@ English | [中文](2026-07-20-error-cause-chain-diagnostics.zh.md) A TUI run against an unreachable DeepSeek endpoint failed with the single notice `fetch failed` and no further detail. Two independent gaps produced that dead end: 1. undici's `fetch` wraps every transport failure (DNS, refused connection, TLS, proxy) in a bare `TypeError: fetch failed` whose actionable detail — `ECONNREFUSED`, `bad port`, the Happy Eyeballs AggregateError — lives on `error.cause`. Every diagnostic boundary in the harness rendered only `error.message` (or `String(error)`, which is equivalent for Errors), so the wrapper masked the diagnosis in the TUI notice, the durable `turn/end` reason, and every logger line. -2. The readline front door (`dsh-stdio`) rendered no failure reason at all: a `turn/end` with `reason.kind === 'error'` printed nothing but the next `> ` prompt, so the same failure in `demo:repl` was pure silence. +2. The readline entry point (`dsh-stdio`) rendered no failure reason at all: a `turn/end` with `reason.kind === 'error'` printed nothing but the next `> ` prompt, so the same failure in `demo:repl` was pure silence. ## Decision diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md index 9911c32b6d..914e983d7e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md @@ -9,7 +9,7 @@ Status: implemented TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed` 通知,没有任何进一步细节。两个独立缺口共同造成了这个死胡同: 1. undici 的 `fetch` 把所有传输层失败(DNS、连接被拒、TLS、代理)包装成裸的 `TypeError: fetch failed`,可操作的细节——`ECONNREFUSED`、`bad port`、Happy Eyeballs 的 AggregateError——都在 `error.cause` 上。harness 里的每个诊断边界都只渲染 `error.message`(或对 Error 等价的 `String(error)`),于是包装层在 TUI 通知、持久化的 `turn/end` reason 和所有日志行里都掩盖了诊断信息。 -2. readline 前门(`dsh-stdio`)完全不渲染失败原因:`reason.kind === 'error'` 的 `turn/end` 只打印下一个 `> ` 提示符,同样的失败在 `demo:repl` 里就是纯粹的沉默。 +2. readline 入口(`dsh-stdio`)完全不渲染失败原因:`reason.kind === 'error'` 的 `turn/end` 只打印下一个 `> ` 提示符,同样的失败在 `demo:repl` 里就是纯粹的沉默。 ## 决策 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 32c80847bb..e60442bb37 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-24-workspace-context.md -2026-06-24-workspace-context.md: 53a580aee50752b7c6daeff5caa42ba6409c8885 +2026-06-24-workspace-context.md: e7a3724847b9dc8cfad11e87b2c96a3ef442bcba 2026-06-24-workspace-context.zh.md: 39c3f52da10b7299301d10bd8b78330cbae8e19c diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 53a580aee5..e7a3724847 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -78,7 +78,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc ## Consequences -Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial, nested, and changed instructions are durable and replayable. The generic session/agent context contract carries typed source data through inbox-staged and durably entered user messages without flattening entries. +Workspace guidance is isolated per session and shared by the demo entry points, Web Host, and every tool presentation mode. Initial, nested, and changed instructions are durable and replayable. The generic session/agent context contract carries typed source data through inbox-staged and durably entered user messages without flattening entries. Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk). diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml index b7181bfba1..af11b3f3f9 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-harness-level-loop.md -2026-07-16-harness-level-loop.md: e84a5738a55988d3b968eac829d9daaf10d7e304 -2026-07-16-harness-level-loop.zh.md: d7cda56d66d4456ac5c3c7d55bb238e85fcacbc5 +2026-07-16-harness-level-loop.md: e5dd94fbf76e27f0843666f29622969635ef2fc3 +2026-07-16-harness-level-loop.zh.md: 773b5ff1f4b07206d0d817c29774435ce24407b4 diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md index e84a5738a5..e5dd94fbf7 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md @@ -70,7 +70,7 @@ The human UX follows the compact Codex shape in the [public OpenAI Codex TUI dis The model receives only `get_goal`, `create_goal`, and `update_goal`. It may create a goal when a direct human request clearly asks for substantial multi-round work, and it may infer that intent in any language. It must not turn routine one-turn work into a goal. Code requires a direct human message in the current live root-agent turn; semantic interpretation remains model judgment. An autonomous goal round may report `complete` or `blocked` for the exact current goal round but cannot edit, pause, resume, or replace the human objective. -TUI mounts the shared command registry and complete goal stack by default and exposes `/goal` through one producer. ACP mounts the goal domain, model tools, and same-session driver but deliberately omits the human command plane. Every effective registered command is discoverable and invocable through every composed command adapter; a plugin incompatible with an application omits its command producer from that composition rather than relying on registry-level surface masks. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. The headless CLI and JSON-RPC front doors do not consume the command plane; ordinary human text can still authorize model goal tools when that stack is composed. +TUI mounts the shared command registry and complete goal stack by default and exposes `/goal` through one producer. ACP mounts the goal domain, model tools, and same-session driver but deliberately omits the human command plane. Every effective registered command is discoverable and invocable through every composed command adapter; a plugin incompatible with an application omits its command producer from that composition rather than relying on registry-level surface masks. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. The headless CLI and JSON-RPC entry points do not consume the command plane; ordinary human text can still authorize model goal tools when that stack is composed. ### Fresh-agent Ralph execution diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md index d7cda56d66..773b5ff1f4 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md @@ -70,7 +70,7 @@ Goal Round 驱动器为每个特定的实时 agent 至多拥有一个待定预 模型只接收 `get_goal`、`create_goal` 和 `update_goal`。当直接人类请求清楚要求大量多 Round 工作时,模型可以创建目标,并且可以从任何语言推断该意图。它不得把日常单 Turn 工作变成目标。代码要求当前实时根 agent Turn 中有一条人类直接发送的消息;语义解释仍是模型判断。自治目标 Round 可以为确切的当前 Goal Round 报告 `complete` 或 `blocked`,但不能编辑、暂停、恢复或替换人类目标。 -TUI 默认挂载共享命令注册表和完整目标栈,并通过一个生产方暴露 `/goal`。ACP(Agent Client Protocol)挂载目标领域、模型工具和同会话驱动器,但有意省略人类命令平面。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI 的 agent 主干要求显式选择加入,以免单次调用方静默变成多 Round 操作。无头 CLI(命令行界面)与 JSON-RPC 前端不消费命令平面;挂载目标栈后,普通人类文本仍可授权模型目标工具。 +TUI 默认挂载共享命令注册表和完整目标栈,并通过一个生产方暴露 `/goal`。ACP(Agent Client Protocol)挂载目标领域、模型工具和同会话驱动器,但有意省略人类命令平面。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI 的 agent 主干要求显式选择加入,以免单次调用方静默变成多 Round 操作。无头 CLI(命令行界面)与 JSON-RPC 运行入口不消费命令平面;挂载目标栈后,普通人类文本仍可授权模型目标工具。 ### 全新 agent Ralph 执行 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 146601b28d..32fa522443 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: f27d799574e61d92123c9ca4e31c5c2a9d3229b4 -2026-07-16-persistent-pty-sessions.zh.md: 0c23619faf00794c2c4e7b3f85ef961faddd99e0 +2026-07-16-persistent-pty-sessions.md: fb9cd06bade7bc357baa738f0d9dd03b7f5b7936 +2026-07-16-persistent-pty-sessions.zh.md: 55a5848c1ab1e8c2cd3b29f2d4748ea4abbe088c diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index f27d799574..fb9cd06bad 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -152,7 +152,7 @@ The package ships concise tool guidance explaining persistent state, owner isola **Include TUI sequences and BEL handling.** Rejected. The source prototype treats those paths as timing-sensitive and still records unresolved alternate-screen and interaction failures. Line-oriented PTY use proves the core value without making those unverified behaviors foundational. -**Use an out-of-process daemon immediately.** Rejected for the initial in-process capability because current persistent front doors already keep a Cordis context alive. A daemon becomes justified by cross-process restoration or multi-client attachment, both deferred here. +**Use an out-of-process daemon immediately.** Rejected for the initial in-process capability because current long-lived entry points already keep a Cordis context alive. A daemon becomes justified by cross-process restoration or multi-client attachment, both deferred here. ## Verification diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 0c23619faf..55a5848c1a 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -152,7 +152,7 @@ plugins: **包含 TUI sequence 与 BEL 处理。**拒绝。源 prototype 将这些路径视为 timing-sensitive,且仍记录未解决的 alternate-screen 和交互失败。行式 PTY 已能证明核心价值,无需把未经验证的行为放进基础层。 -**立即采用进程外 daemon。**初始的进程内功能不采用,因为当前持久 front door 已能维持 Cordis context。跨进程恢复或多客户端 attach 会让 daemon 变得合理,但两者都已推迟。 +**立即采用进程外 daemon。**初始的进程内功能不采用,因为当前长驻的运行入口已能维持 Cordis context。跨进程恢复或多客户端 attach 会让 daemon 变得合理,但两者都已推迟。 ## 验证 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml index 5b39ad725e..b45809724c 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-human-goal-command.md -2026-07-19-human-goal-command.md: 5fdd80f7423b80e84e58f7379130ee59a2e8a723 -2026-07-19-human-goal-command.zh.md: 89d29497abfc758ff8373d272aa8620ca1bcfcc2 +2026-07-19-human-goal-command.md: d68e4025a4d37d07211f15ddfc6069bc7c637124 +2026-07-19-human-goal-command.zh.md: dbda35c731ccdc7aff2d4213a3df8b78070319df diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md index 5fdd80f742..d68e4025a4 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md @@ -68,5 +68,5 @@ The producer suite uses the real command registry, goal service, agent registry, - The portable command contract has no modal editor or confirmation interaction; inline edit and explicit clear are intentional until a general cross-surface interaction primitive exists. - `/goal` does not accept a per-command round cap. Deployment config owns the default, and the authorized model tool can edit a cap after direct human instruction. - TUI renders portable plain text rather than a continuously updated goal status widget. Reconnectable command output and adapter-specific status indicators are deferred. -- The ACP automation server, headless CLI, and JSON-RPC front doors do not consume the command registry. +- The ACP automation server, headless CLI, and JSON-RPC entry points do not consume the command registry. - The command observes and mutates state but does not certify completion or blockers. Evaluator-backed certification remains deferred to a separate policy layer with an explicit authority and isolation contract. diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md index 89d29497ab..dbda35c731 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md @@ -68,5 +68,5 @@ TUI 应用包作出相反的产品选择。它默认让 `goals` 使用所有者 - 可移植命令约定没有模态编辑器或确认交互;在出现通用跨界面交互原语之前,行内编辑与明确清除是有意选择。 - `/goal` 不接受逐命令 Round 上限。部署配置拥有默认值;得到直接人类指示后,已授权模型工具可以编辑上限。 - TUI 渲染可移植纯文本,而不是持续更新的目标状态组件。可重连命令输出和适配器专用状态指示器予以延期。 -- ACP 自动化服务器、无头 CLI 与 JSON-RPC 前端不消费命令注册表。 +- ACP 自动化服务器、无头 CLI 与 JSON-RPC 运行入口不消费命令注册表。 - 该命令观察并改变状态,但不认证完成或阻塞。基于评估器的认证延期到具有明确权限与隔离约定的独立策略层。 diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml index 26dde53462..83e3319f6d 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-19-plugin-command-registration.md -2026-07-19-plugin-command-registration.md: c8f0f2772a41948e9eb257a16f40194518c568f9 -2026-07-19-plugin-command-registration.zh.md: a8f7a3d1e7947aeb41344a72106cee55d249010c +2026-07-19-plugin-command-registration.md: 76feba84492bd6b245246a6f1fb1e8f68d555684 +2026-07-19-plugin-command-registration.zh.md: e89cb79ba1e15dfefa527fe7bc37c09c3449f5b4 diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md index c8f0f2772a..76feba8449 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md @@ -12,7 +12,7 @@ A shared mechanism must remain a UI concern rather than a model tool or agent-lo ## Decision -`@deepseek-ai/dsh-commands` in `packages/interaction/commands/` is the product command registry. The TUI app bundle mounts it beside its consuming front door; the [automation-only ACP app](../simplification/2026-07-23-acp-automation-only-protocol.md) and the executor-less, UI-less agent spine omit it. TUI injects the service, while command producers depend only on the registry and any domain they operate. +`@deepseek-ai/dsh-commands` in `packages/interaction/commands/` is the product command registry. The TUI app bundle mounts it beside its consuming front end; the [automation-only ACP app](../simplification/2026-07-23-acp-automation-only-protocol.md) and the executor-less, UI-less agent spine omit it. TUI injects the service, while command producers depend only on the registry and any domain they operate. ### Registry contract @@ -50,7 +50,7 @@ TUI tests exercise all migrated built-ins, live plugin discovery, help/autocompl - **Keep adapter-local switches** — rejected because optional plugins cannot contribute discovery and behavior without editing the TUI. - **Represent human commands as model tools** — rejected because discovery and direct invocation are human UI behavior; routing through the model adds latency, token cost, and reinterpretation. -- **Put the registry in the core agent spine** — rejected because UI-less front doors do not consume it, while TUI can compose it explicitly. +- **Put the registry in the core agent spine** — rejected because UI-less entry points do not consume it, while TUI can compose it explicitly. - **Make `dsh-agent-loop` inject commands** — rejected because the loop does not execute or discover human commands. Agent-scoped producers declare the UI dependency in a child plugin instead. - **Attach adapter masks to each definition** — rejected because support is a composition fact, not command-domain state. Every composed adapter exposes a registered command; an incompatible plugin omits registration in that deployment. - **Send unknown slash input to the model** — rejected because typoed or unavailable direct actions must fail predictably rather than change execution planes. @@ -68,4 +68,4 @@ TUI tests exercise all migrated built-ins, live plugin discovery, help/autocompl - Input metadata is limited to an unstructured text hint. Typed forms, argument schemas, and completion providers remain command-owned or require a later registry or consumer extension. - Generic command output is live-only and is not reconstructed after TUI restart. - Registry cancellation stops awaiting immediately, but external work stops only when a handler cooperates with its signal. -- The ACP automation server, headless CLI, and JSON-RPC SDK front doors do not expose the command plane; only TUI consumes it. +- The ACP automation server, headless CLI, and JSON-RPC SDK entry points do not expose the command plane; only TUI consumes it. diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md index a8f7a3d1e7..e89cb79ba1 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md @@ -12,7 +12,7 @@ TUI 拥有斜杠命令。如果命令名、帮助文本、自动补全、分派 ## 决策 -位于 `packages/interaction/commands/` 的 `@deepseek-ai/dsh-commands` 是产品命令注册表。TUI 应用 bundle(组合包)把它挂载在消费该服务的入口旁;[仅面向自动化的 ACP(Agent Client Protocol)应用](../simplification/2026-07-23-acp-automation-only-protocol.md)和无执行器、无 UI 的智能体 spine(主干)都省略该服务。TUI 注入该服务,命令生产者只依赖注册表及其操作的领域。 +位于 `packages/interaction/commands/` 的 `@deepseek-ai/dsh-commands` 是产品命令注册表。TUI 应用 bundle(组合包)把它挂载在消费该服务的前端旁;[仅面向自动化的 ACP(Agent Client Protocol)应用](../simplification/2026-07-23-acp-automation-only-protocol.md)和无执行器、无 UI 的智能体 spine(主干)都省略该服务。TUI 注入该服务,命令生产者只依赖注册表及其操作的领域。 ### 注册表约定 @@ -50,7 +50,7 @@ TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与 - **保留适配器本地 switch**——不予采纳,因为可选插件无法贡献发现与行为,除非修改 TUI。 - **把人类命令表示为模型工具**——不予采纳,因为发现与直接调用属于人类 UI 行为;经由模型路由会增加延迟、token 成本和重新解释。 -- **把注册表放入核心智能体主干**——不予采纳,因为无 UI 前端不消费它,而 TUI 可以显式组合它。 +- **把注册表放入核心智能体主干**——不予采纳,因为无 UI 运行入口不消费它,而 TUI 可以显式组合它。 - **让 `dsh-agent-loop` 注入 commands**——不予采纳,因为循环不执行也不发现人类命令。智能体作用域生产者改为在子插件中声明 UI 依赖。 - **为每个定义附加适配器掩码**——不予采纳,因为支持能力是组合事实,而不是命令领域状态。每个已组合适配器都暴露已注册命令;不兼容插件不会在该部署中注册。 - **把未知斜杠输入发送给模型**——不予采纳,因为输入错误或不可用的直接操作必须可预测地失败,而不能改变执行平面。 @@ -68,4 +68,4 @@ TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与 - 输入元数据仅限非结构化文本提示。类型化表单、参数模式和补全提供器仍由命令拥有,或需要后续注册表或消费方扩展。 - 通用命令输出仅实时存在,TUI 重启后不会重建。 - 注册表取消会立即停止等待,但外部工作只有在处理器配合信号时才会停止。 -- ACP 自动化服务器、无头 CLI 与 JSON-RPC SDK 前端不暴露命令平面;只有 TUI 消费它。 +- ACP 自动化服务器、无头 CLI 与 JSON-RPC SDK 运行入口不暴露命令平面;只有 TUI 消费它。 diff --git a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml index ee932dd716..461e95c391 100644 --- a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-21-local-instruction-overlay.md -2026-07-21-local-instruction-overlay.md: 3c7b2141b0515b5e667be4add6ad765e26c88cd8 +2026-07-21-local-instruction-overlay.md: fb5f916d426595a80bbbaa4192e4a3975b922b8a 2026-07-21-local-instruction-overlay.zh.md: c97ed04607f497d829da0e904c248836252c73f7 diff --git a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md index 3c7b2141b0..fb5f916d42 100644 --- a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md +++ b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md @@ -26,7 +26,7 @@ The base and local candidates in one directory must stay independent across base **Keep it opt-in through `instructionFileCandidates`.** Rejected: one directory has a single winner, so a `.local.` name added to that list shadows the base file rather than supplementing it. The packages guidance to keep opt-ins out of shipped defaults is outweighed here by strong prior art and the user-facing expectation that `.local.` files are always read. -**Default at the product `cordis.yml` level instead of the plugin schema.** Rejected: it would enable `.local.` only for whichever front door remembered to opt in, splitting behavior across TUI/ACP/headless and duplicating a value that belongs beside the existing candidate default. +**Default at the product `cordis.yml` level instead of the plugin schema.** Rejected: it would enable `.local.` only for whichever entry point remembered to opt in, splitting behavior across TUI/ACP/headless and duplicating a value that belongs beside the existing candidate default. **Reuse the bare directory as the scope key for base and local files.** Rejected: base and local files in one directory would collide in every scope-keyed map, so a change to one would suppress or overwrite the other. A distinct scope key per candidate keeps them independent without widening the persisted metadata shape. diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index 626d98b3c8..da84a269ed 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: 78017b14a806f8e609a85e340094cd2a349d47e1 -2026-07-24-web-session-model-selector.zh.md: 87a342721f5e47f5bcedfafb578eb6916101d2f5 +2026-07-24-web-session-model-selector.md: e6a96ac62f69a3bd312f61cc920caa259d2dc5b0 +2026-07-24-web-session-model-selector.zh.md: 5a0245359d69ac6e59a20dc3276b9411c4b25e23 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index 78017b14a8..e6a96ac62f 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -6,7 +6,7 @@ English | [中文](2026-07-24-web-session-model-selector.zh.md) ## Problem -The Web conversation needs a visible, mutable session model selection sourced from the Host. Copying TUI presentation or hardcoding DeepSeek models in the browser would split model discovery and step-boundary semantics across front doors. A switch made while a response is running also needs one atomic boundary: prompt variables and request routing cannot observe different selections. +The Web conversation needs a visible, mutable session model selection sourced from the Host. Copying TUI presentation or hardcoding DeepSeek models in the browser would split model discovery and step-boundary semantics across front ends. A switch made while a response is running also needs one atomic boundary: prompt variables and request routing cannot observe different selections. ## Decision diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index 87a342721f..5a0245359d 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -Web 对话需要一项由 Host 提供、可见且可更改的会话模型选择。如果照搬 TUI 的呈现方式,或在浏览器中硬编码 DeepSeek 模型,就会让模型发现逻辑和步骤边界语义分散到不同前门中。响应运行期间发生的切换还需要一个原子边界:提示词变量与请求路由不能观测到不同的选择。 +Web 对话需要一项由 Host 提供、可见且可更改的会话模型选择。如果照搬 TUI 的呈现方式,或在浏览器中硬编码 DeepSeek 模型,就会让模型发现逻辑和步骤边界语义分散到不同前端中。响应运行期间发生的切换还需要一个原子边界:提示词变量与请求路由不能观测到不同的选择。 ## 决策 Web Host 为每个新建或恢复的 Agent 安装 `ModelSelection`。如果会话已经使用过模型,提供方/模型/推理(reasoning)选择来自最新的 `request/header`;否则来自 `ctx.agentDefaultModel`。`session.selectModel` 会赋值会话级选择,提示词组装则将它与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一个实际采用的选择通过完整的 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。 -会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。两个前门对这一状态给出不同回答:TUI 把未列出的当前模型渲染为独立一行,Web 则显示未设置状态的触发器标签并要求选择替代模型。Web 是编辑目录的 surface,因此缺席的目录行代表一项待作出的选择;TUI 只从现有行中选择。显示未设置标签的 Web composer 仍可以使用当前可路由选择发送消息。精确解析决定提供方/模型组合与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在赋值该选择前具体化适配器配置的默认值。 +会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。两个前端对这一状态给出不同回答:TUI 把未列出的当前模型渲染为独立一行,Web 则显示未设置状态的触发器标签并要求选择替代模型。Web 是编辑目录所在的前端,因此缺席的目录行代表一项待作出的选择;TUI 只从现有行中选择。显示未设置标签的 Web composer 仍可以使用当前可路由选择发送消息。精确解析决定提供方/模型组合与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在赋值该选择前具体化适配器配置的默认值。 浏览器中的 `ModelService` 为每个实时会话持有一个 `ModelDirectory`。其快照包含当前完整的 `ModelSelection`、分组目录、提供方失败记录、操作错误,以及 `idle`、`loading`、`ready`、`selecting`、`error` 状态。挂载时会预先填充触发器标签,此后每次打开菜单都会刷新目录。目录与选择调用共用操作代次,防止较早响应覆盖较新结果;连接重置会先丢弃当前进程中的投影,再恢复 Host 选择。失败时保留先前的选择和可用分组。 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml index 684931decb..de5133b527 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-default-model-follows-the-picker.md -2026-08-07-default-model-follows-the-picker.md: ed7e7a424d2cacadea890506fd9150ffdf7a993c -2026-08-07-default-model-follows-the-picker.zh.md: 523c6f917dedf21c726ce5631226ba545126c757 +2026-08-07-default-model-follows-the-picker.md: 2a3ada55486345c0f58f0767bed5a93ecba04b88 +2026-08-07-default-model-follows-the-picker.zh.md: 08fecc6ec9b177f6424ada3172c67018ac72baea diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md index ed7e7a424d..2a3ada5548 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md @@ -6,13 +6,13 @@ English | [中文](2026-08-07-default-model-follows-the-picker.zh.md) ## Problem -A session model picker and a deployment default are two layers of the same preference. If the picker affects only its addressed session, the next blank session can select a different model with no user-facing way to align the default. If the default lives inside a Host gateway, direct Agent front doors cannot share it without depending on Host or duplicating state. +A session model picker and a deployment default are two layers of the same preference. If the picker affects only its addressed session, the next blank session can select a different model with no user-facing way to align the default. If the default lives inside a Host gateway, direct Agent entry points cannot share it without depending on Host or duplicating state. Reasoning effort makes the persistence shape significant: a model selection without an effort must clear a stored effort, or the next Agent may apply an effort that its selected model does not accept. ## Decision -`AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is front-door-neutral, so direct creation and ApiProxy-backed creation share one default ([headless direct core front door](../architecture/2026-08-09-headless-direct-core-front-door.md)). `workspaceRoot` remains ApiProxy config because it is a Host launcher fact rather than model state. +`AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is entry-point-neutral, so direct creation and ApiProxy-backed creation share one default ([headless direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md)). `workspaceRoot` remains ApiProxy config because it is a Host launcher fact rather than model state. `reasoningEffort` belongs to the Settings section but not to the plugin config. Settings layers merge by field, so a configured effort would survive a user selection that omits it. `saveSelection()` instead writes the complete user section; absence therefore clears a stored effort. A deployment-wide effort default belongs to the adapter profile, which resolves it per model. diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md index 523c6f917d..08fecc6ec9 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -会话模型选择器与部署默认值是同一项偏好的两个层次。如果选择器只影响其所在会话,下一个空白会话可能选择不同模型,用户却没有途径使默认值与选择器一致。如果默认值位于 Host 网关内部,直接创建 Agent 的前门只有依赖 Host 或复制状态才能共享它。 +会话模型选择器与部署默认值是同一项偏好的两个层次。如果选择器只影响其所在会话,下一个空白会话可能选择不同模型,用户却没有途径使默认值与选择器一致。如果默认值位于 Host 网关内部,直接创建 Agent 的入口只有依赖 Host 或复制状态才能共享它。 推理强度使持久化形态成为约定的一部分:不含强度的模型选择必须清除已存强度,否则下一个 Agent 可能会采用所选模型不接受的强度。 ## 决定 -`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定前门,因此直接创建与 ApiProxy 支撑的创建共享同一个默认值([headless 直接 core 前门](../architecture/2026-08-09-headless-direct-core-front-door.md))。`workspaceRoot` 仍是 ApiProxy 配置,因为它是 Host 启动器事实,而不是模型状态。 +`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定入口,因此直接创建与 ApiProxy 支撑的创建共享同一个默认值([headless 直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md))。`workspaceRoot` 仍是 ApiProxy 配置,因为它是 Host 启动器事实,而不是模型状态。 `reasoningEffort` 属于 Settings 分节,但不属于插件配置。Settings 层按字段合并,因此已配置的强度会在用户选择省略它时继续存在。`saveSelection()` 写入完整的用户分节;缺席值由此清除已存强度。部署级强度默认值属于适配器 profile,并由它按模型解析。 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml index f4e308c8f2..730f57e681 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md -2026-08-08-dsh-run-headless-command.md: 566eeb5b2a09a0d07d72a68e4a5f449d2822e708 -2026-08-08-dsh-run-headless-command.zh.md: 177410e783a37026940829da5f81343ddc61cb29 +2026-08-08-dsh-run-headless-command.md: ed095f4077a23e51bffb647d24eed19ba09e11ed +2026-08-08-dsh-run-headless-command.zh.md: 89d54e35573f14786e05d648f2b42891ca27a043 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md index 566eeb5b2a..ed095f4077 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md @@ -22,7 +22,7 @@ dsh run [--profile ] [--patch ...] `RunInvocation` is a distinct `DshInvocation` member. The generic profile invocation carries no task state and accepts no positional arguments. Both dispatch paths use `runProfile`: profile boot omits `task`, while `run` supplies it. A one-shot profile without `headless-runner` fails through the composed-row check, and profile boot containing that row without a task points to `dsh run --profile ""`. -The [profile plugin bundle decision](../architecture/2026-08-05-profile-plugin-bundles.md) owns composition. [Headless is a direct core front door](../architecture/2026-08-09-headless-direct-core-front-door.md) owns the execution contract: one fresh persisted Session, final assistant text on stdout, completed/non-completed exit mapping, empty stderr on success, no listening port, and bounded signal shutdown after Agent quiescence and Session flush. +The [profile plugin bundle decision](../architecture/2026-08-05-profile-plugin-bundles.md) owns composition. [Headless is a direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md) owns the execution contract: one fresh persisted Session, final assistant text on stdout, completed/non-completed exit mapping, empty stderr on success, no listening port, and bounded signal shutdown after Agent quiescence and Session flush. The `run` verb belongs only to one-shot task execution. Application-file launch requires a distinct command name. diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md index 177410e783..89d54e3557 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -22,7 +22,7 @@ dsh run [--profile ] [--patch ...] `RunInvocation` 是单独的 `DshInvocation` 成员。通用 profile 调用不携带任务状态,也不接受位置参数。两条分派路径都使用 `runProfile`:profile 启动省略 `task`,而 `run` 提供该字段。缺少 `headless-runner` 的一次性 profile 会触发组合行检查;如果启动的 profile 包含该行却未提供任务,错误会指向 `dsh run --profile ""`。 -[profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)负责组合。[Headless 是直接 core 前门](../architecture/2026-08-09-headless-direct-core-front-door.md)负责执行约定:一个新的持久化会话、stdout 上的最终 assistant 文本、completed/非 completed 的退出状态映射、成功时为空的 stderr、无监听端口,以及 Agent 完全停稳且会话 flush 后的有界信号关闭。 +[profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)负责组合。[Headless 是直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md)负责执行约定:一个新的持久化会话、stdout 上的最终 assistant 文本、completed/非 completed 的退出状态映射、成功时为空的 stderr、无监听端口,以及 Agent 完全停稳且会话 flush 后的有界信号关闭。 `run` 动词只负责一次性任务执行。应用文件启动需要不同的命令名。 diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml index 005b3cad67..b44a25790d 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-user-explicit-skill-invocation.md -2026-08-08-user-explicit-skill-invocation.md: 74d9f01f191005db6d3d283a3c56f5ee664447f8 -2026-08-08-user-explicit-skill-invocation.zh.md: 0f7c9e1261dda796d988c17ddf7199b74be4133e +2026-08-08-user-explicit-skill-invocation.md: a7c2c15703af318cb4112f2d3dfda698bc5e3bc2 +2026-08-08-user-explicit-skill-invocation.zh.md: a8d685e5eb12766bebddebb7f5993579cf08531e diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md index 74d9f01f19..a7c2c15703 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -10,14 +10,14 @@ A `disable-model-invocation: true` skill is user-only by design: it never enters ## Decision -User-explicit invocation is a host-side pre-step injection, uniform for every user-invocable skill and every front end: +User-explicit invocation is a host-side pre-step injection, uniform for every user-invocable skill and every entry point: - `dsh-tool-skill` registers a second `agent/pre-step` listener (beside its catalog listener, the same seam `workspace-instructions` and the runtime-context snapshot ride): it scans the step's claimed messages for whitespace-bounded `/name` tokens — anywhere in the text, the same word-boundary shape the transcript chip decoration uses — collects first-seen-deduplicated names, loads each through `ctx.skills.get`, checks `isUserInvocable` on the loaded definition (the single lookup that produces what is injected), renders it with the shared `renderSkillContent`, and appends the injections after every other injection of the step: background first (workspace rules, runtime policy, catalog), the material the model must act on last, closest to its answer. Registration order pins the placement — the gesture listener registers before the catalog listener, so the waterfall hands it the catalog-bearing list to extend. - Precision is closed-set matching, exactly like slash commands: `/goal` resolves against the command registry, `/name` against the workspace's user-invocable skill directory; a miss stays ordinary prose, so nothing is ever guessed. Only `source.kind === 'user'` messages are scanned — external text cannot forge a gesture. Paths (`/usr/bin`), fractions (`5/8`), and prefixed tokens (`foo/name`) all break the boundary. - The client keeps the [plain-text-reference decision](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): a menu pick lands the literal `/name ` and the prompt ships it verbatim; ui-skill implements no adjudication hooks and no reference codec. `skill.list` (now the domain's only RPC) serves every user-invocable skill with `modelInvocable` so menus mark user-only entries. A name shared with a host command resolves to the command — adjudication claims the line client-side before it becomes a prompt. - The injection is a `user`-role message carrying the `skill-invocation` source (`{ name, form: 'instructions' }`), so `user/message` logging, the context-injection transcript row (labelled with the skill name), and replay all come free; `renderSkillContent` lives in the `dsh-skill` seam, shared verbatim with the `skill` tool result, and the catalog's closing sentence tells the model to follow an injected block instead of re-loading it. -Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous that user-explicit triggering is programmatic injection with zero model participation; the final shape is closest to Codex's core-side `$name` mention scanning, which likewise frees every front end from implementing recognition. +Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous that user-explicit triggering is programmatic injection with zero model participation; the final shape is closest to Codex's core-side `$name` mention scanning, which likewise frees every entry point from implementing recognition. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md index 0f7c9e1261..a8d685e5eb 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -10,14 +10,14 @@ Status: implemented ## 决策 -用户显式调用是一次宿主侧的 pre-step 注入,对每一个用户可调用的 skill 和每一种前端一致: +用户显式调用是一次宿主侧的 pre-step 注入,对每一个用户可调用的 skill 和每一种运行入口一致: - `dsh-tool-skill` 注册第二个 `agent/pre-step` 监听器(与其目录监听器并列,也是 `workspace-instructions` 与运行时上下文快照搭乘的同一 seam):它在该步骤已认领的消息中扫描以空白为界的 `/name` token——文本中任意位置均可,与 transcript(文本记录)chip 装饰所用的词边界形状相同——收集按首见去重的名称,逐个经 `ctx.skills.get` 加载,在已加载定义上检查 `isUserInvocable`(产生注入内容的正是这同一次查找),用共享的 `renderSkillContent` 渲染,并把注入追加在该步骤所有其他注入之后:背景在前(工作区规则、运行时策略、目录),模型必须着手处理的材料在最后、最贴近它的回答。注册顺序钉住了这一位置——手势监听器先于目录监听器注册,因此 waterfall(瀑布式事件)会把携带目录的列表交给它来扩展。 - 精确性来自封闭集合匹配,与斜杠命令完全一致:`/goal` 对照命令注册表解析,`/name` 对照工作区的用户可调用 skill 目录解析;未命中即保持为普通行文,因此绝不猜测。只扫描 `source.kind === 'user'` 的消息——外部文本无法伪造手势。路径(`/usr/bin`)、分数(`5/8`)与带前缀的 token(`foo/name`)都会破坏该边界。 - 客户端沿用[纯文本引用决策](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):菜单 pick 落下字面文本 `/name `,该文本随提示词原样提交;ui-skill 不实现任何裁决钩子,也没有引用 codec。`skill.list`(现在是该领域唯一的 RPC)提供每一个用户可调用的 skill 并携带 `modelInvocable`,供菜单标出仅限用户的条目。与宿主命令同名的名称解析为命令——客户端会在该行成为提示词之前完成裁决并将其认领。 - 注入是一条携带 `skill-invocation` 来源(`{ name, form: 'instructions' }`)的 `user` 角色消息,因此 `user/message` 落账、上下文注入的 transcript 行(以 skill 名称标注)与回放全部免费获得;`renderSkillContent` 位于 `dsh-skill` seam,由注入和 `skill` 工具结果共用,二者内容逐字相同,目录的结尾一句会告诉模型遵循注入块而不是重新加载。 -同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)一致表明:用户显式触发都是模型零参与的程序化注入;最终形态最接近 Codex 核心侧的 `$name` mention 扫描——它同样让每一种前端免于自行实现识别。 +同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)一致表明:用户显式触发都是模型零参与的程序化注入;最终形态最接近 Codex 核心侧的 `$name` mention 扫描——它同样让每一种运行入口免于自行实现识别。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml index ae738e3fad..95b38715c2 100644 --- a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md -2026-07-05-uniform-agent-note-format.md: 06082251c1b96c90ed470d84224662e00e29791b +2026-07-05-uniform-agent-note-format.md: c05d81c700b81f0d619173a261f405b1cc039df1 2026-07-05-uniform-agent-note-format.zh.md: 3daa686b64b31ee2638b25dd4c42e5d8172f1d97 diff --git a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md index 06082251c1..c05d81c700 100644 --- a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md +++ b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md @@ -23,7 +23,7 @@ The whole corpus was normalized in the same change that defined the format — t - **A bare `# ` H1** — rejected: the `Agent Note: ` prefix self-describes the genre when a file is read outside its tree, and the format gate prevents it from drifting. - **`## What we give up` as the implemented closer** (the README's own phrase for what an Agent Note records) — rejected: it names only costs, and an honest consequences section records what the trade-off bought as well. - **Convention without a gate** (write the contract down, enforce by review) — rejected: the slop checklist already outlawed spec-speak in `implemented/` by convention, and nineteen files show what convention alone achieves here. -- **A standalone `FORMAT.md` contract file** — rejected because one front door carrying layout, classification, and format is easier to discover and maintain than two contract files. +- **A standalone `FORMAT.md` contract file** — rejected because one entry point carrying layout, classification, and format is easier to discover and maintain than two contract files. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml index ce16a025cc..5c5386b2dd 100644 --- a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md -2026-07-19-remove-generated-agent-note-index.md: ee85ec0757d5924f5784c43a50003eb96e0a9531 +2026-07-19-remove-generated-agent-note-index.md: 652ac72afe69284240667e61af1f3fbfcb182ddb 2026-07-19-remove-generated-agent-note-index.zh.md: 6e1967fbde0c6ac59bbc3a184dad68c989efadbc diff --git a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md index ee85ec0757..652ac72afe 100644 --- a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md +++ b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md @@ -12,7 +12,7 @@ The centralized chronological list adds little discovery value beyond browsing t ## Decision -The lifecycle/class filesystem tree is the Agent Note inventory. [README.md](../../README.md) remains the curated front door and contract, while ordinary tree navigation and repository search provide discovery. +The lifecycle/class filesystem tree is the Agent Note inventory. [README.md](../../README.md) remains the curated entry point and contract, while ordinary tree navigation and repository search provide discovery. `scripts/agent-note-tree.ts` owns the closed lifecycle/class sets and structural walker. `verify-agent-note-classification` validates that tree and rejects the legacy homes and a root `INDEX.md`; it does not render or freshness-check a centralized list. diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml index d85aa1fe0c..61942db772 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-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 .agents/notes/implemented/process/2026-07-22-product-first-root-readme.md -2026-07-22-product-first-root-readme.md: 32542a45019d64ed1826d4eb21e68c67c3c3d52e +2026-07-22-product-first-root-readme.md: 00f6084da9e83135c881abfef21b0b249cd4e30a 2026-07-22-product-first-root-readme.zh.md: 8ef6f4b99ca2c935183a225b6357d2d128edb3b0 diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md index 32542a4501..00f6084da9 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md @@ -6,7 +6,7 @@ English | [中文](2026-07-22-product-first-root-readme.zh.md) ## Problem -The root README is the repository's product front door. Its product-first structure and established voice remain useful, but concrete entry points and capability claims drift as the runtime grows. Rewriting sections whose facts remain correct increases the review surface and discards language that already works. +The root README is the repository's product entry point. Its product-first structure and established voice remain useful, but concrete entry points and capability claims drift as the runtime grows. Rewriting sections whose facts remain correct increases the review surface and discards language that already works. ## Decision @@ -26,7 +26,7 @@ Detailed package and service inventories remain at their owning documentation. T **Use a long marketing page with screenshots, badges, and duplicated tutorials.** Rich media can demonstrate a stable product journey, but it ages separately from commands and source contracts. The root stays compact and links to runnable examples and owned guides. -**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer front door have different navigation and maintenance needs. +**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer entry point have different navigation and maintenance needs. ## Consequences diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml index b124dbfd9e..e305a9d10e 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-03-package-anchored-subsystem-pages.md -2026-08-03-package-anchored-subsystem-pages.md: f429f3d41c1f152e83faeb12c379d221627e767f -2026-08-03-package-anchored-subsystem-pages.zh.md: 3a56fa39357591197606a28b49cf0eac8f58963e +2026-08-03-package-anchored-subsystem-pages.md: 2a47f35e9d3755286bed7d42f59fd21e30f9148d +2026-08-03-package-anchored-subsystem-pages.zh.md: 53216a430b979f0612f256aaec9774b88fe14bbd diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md index f429f3d41c..2a47f35e9d 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md @@ -14,7 +14,7 @@ Every `docs/subsystems/` page anchors to the package or package group that decla Every type a generated signature references must resolve somewhere in the folder: the agent ownership vocabulary moved from the generator's `TYPE_LINK_EXEMPTIONS` into `LINK_MAP → core.md`, so exemptions are reserved for genuinely service-local or vendored shapes. Each pasted declaration has one home (`SessionEvent` lives on [session.md](../../../../docs/subsystems/session.md); core.md summarizes and links). -Every `packages/<group>/README.md` pair is a thin front door in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. Load-bearing prose that outgrows that shape relocates to the owning subsystems page rather than being deleted. +Every `packages/<group>/README.md` pair is a thin entry point in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. Load-bearing prose that outgrows that shape relocates to the owning subsystems page rather than being deleted. The [subsystems README](../../../../docs/subsystems/README.md) indexes every page in the folder on both language sides; `scripts/project-doc-site.spec.ts` enforces one table row per page, so a page added by a later PR (or absorbed in a merge) cannot silently miss the index. diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md index 3a56fa3935..53216a430b 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md @@ -14,7 +14,7 @@ Status: implemented 生成签名引用的每个类型都必须能在目录中某处解析:agent 所有权词汇从生成器的 `TYPE_LINK_EXEMPTIONS` 移入 `LINK_MAP → core.md`,因此豁免只留给真正服务本地或 vendored 的形状。每个粘贴的声明只有一个家(`SessionEvent` 位于 [session.md](../../../../docs/subsystems/session.md);core.md 概括并链接)。 -每个 `packages/<group>/README.md` 配对都是统一形状的轻薄门面:一段以「为什么」开头的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向拥有方子系统页面的收尾指针。超出该形状的承重散文迁移到拥有方子系统页面,而非删除。 +每个 `packages/<group>/README.md` 配对都是统一形状的精简入口:一段以「为什么」开头的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向拥有方子系统页面的收尾指针。超出该形状的承重散文迁移到拥有方子系统页面,而非删除。 [子系统 README](../../../../docs/subsystems/README.md) 在两个语言侧索引目录中的每一页;`scripts/project-doc-site.spec.ts` 强制每页一行表格,因此后续 PR 新增(或合并吸收)的页面无法悄悄缺席索引。 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml index d09614a7fa..66e573773b 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md -2026-07-23-acp-automation-only-protocol.md: 29da3025251f9826c5780493bb2725b114a40601 +2026-07-23-acp-automation-only-protocol.md: 56c433acf59d3a0c4b5c8b6605e422d3c4efede2 2026-07-23-acp-automation-only-protocol.zh.md: bf326ce9f6fea8c55b842bc61e3d8521032ea4d1 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md index 29da302525..56c433acf5 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md @@ -46,7 +46,7 @@ Protocol and lifecycle tests pin stop-reason and prompt codecs, version negotiat ## Consequences -ACP has a narrow contract suitable for agents and automation, while TUI and Web own human interaction and presentation. The package has fewer injected services, dependencies, protocol branches, and lifecycle states, and it no longer claims compatibility as a general editor front door. +ACP has a narrow contract suitable for agents and automation, while TUI and Web own human interaction and presentation. The package has fewer injected services, dependencies, protocol branches, and lifecycle states, and it no longer claims compatibility as a general editor entry point. Automation clients receive complete committed text rather than token deltas or structured tool UI. They inspect durable logs or another API when they need reasoning, tool traces, titles, or richer state. Fresh-session-only operation also means callers that need durable browsing or resume use a host API rather than ACP. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml index 73c3fb6e55..bcf543a344 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md -2026-08-04-remove-tui-package.md: f048a04db02b582d038ebf549999c0fda50e30d3 +2026-08-04-remove-tui-package.md: 19cc7d1a89a55bb57a69b9fce301f48f89384acd 2026-08-04-remove-tui-package.zh.md: be18cbd33cd4a2bb592de4e7986b2786da272d0f diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md index f048a04db0..19cc7d1a89 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md @@ -8,7 +8,7 @@ English | [中文](2026-08-04-remove-tui-package.zh.md) Removing the implicit `dsh` terminal application left `@deepseek-ai/dsh-tui` without a shipped composition. The package still carried a terminal renderer, interactive command and question adapters, extension overlays, snapshot fixtures, a patched `pi-tui` dependency, and SDK scaffolding that advertised TUI as a supported application interface. Keeping that surface required maintaining a product-sized frontend whose only remaining consumer was the project generator itself. -The package also made the repository's supported application inventory misleading. Current runnable products use Web, ACP, JSON-RPC, or one-shot CLI front doors, while the SDK continued to offer a terminal choice that no example or product command exercised. +The package also made the repository's supported application inventory misleading. Current runnable products use Web, ACP, JSON-RPC, or one-shot CLI entry points, while the SDK continued to offer a terminal choice that no example or product command exercised. ## Decision @@ -34,6 +34,6 @@ Repository searches and generated catalogs contain no TUI package, dependency pa ## Consequences -DeepSeek Harness has no terminal UI package or generated TUI application. Existing imports, `cordis.yml` rows, SDK `--interface=tui` requests, and projects that depend on the package fail instead of being translated. Web remains the shipped interactive surface; ACP, JSON-RPC, and one-shot CLI remain the non-Web front doors. +DeepSeek Harness has no terminal UI package or generated TUI application. Existing imports, `cordis.yml` rows, SDK `--interface=tui` requests, and projects that depend on the package fail instead of being translated. Web remains the shipped interactive surface; ACP, JSON-RPC, and one-shot CLI remain the non-Web entry points. The provider-neutral command, user-interaction, approval, tool-presentation, PTY, and session-projection capabilities remain available to other hosts. Reintroducing a terminal frontend requires a named product or deployment, an explicit package boundary, a concrete interaction provider, and assembled lifecycle and transcript acceptance for that frontend. diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml index 9a88d14722..217265168d 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md -2026-08-08-remove-cli-demo.md: c1153f5e9fcc89585e926f8f088829c849d1f6c1 +2026-08-08-remove-cli-demo.md: 403e01f94c976d2d17eb391830721b31675cd6a9 2026-08-08-remove-cli-demo.zh.md: 7f11e0c17a15454b99b32d14ea6eda177f4b01f6 diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md index c1153f5e9f..403e01f94c 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md @@ -6,7 +6,7 @@ English | [中文](2026-08-08-remove-cli-demo.zh.md) ## Problem -After [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two front doors also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. +After [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two entry points also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. The replay suites still need canonical session events to pin assembled backend behavior. That testing need does not require a published command or compatibility contract. diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 3e3918c2ab..e4a719379e 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -243,7 +243,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con shutdown.interrupt(code) } // Signals own teardown throughout the startup window, not only after boot() - // settles: an inserted front door can publish readiness before sibling rows + // settles: an inserted entry point can publish readiness before sibling rows // finish mounting. process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) }) process.on('SIGINT', () => { interrupt(130) }) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 911b25d0cc..22c74a441e 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 90d64fb0ac62020e13a7ce995c8e3273a5a9f906 +architecture.md: ebf05397cb67cea336dd36a4d9416d43b6002d4e architecture.zh.md: fec9a00484c495b0eed4773f262bb44543e304bd diff --git a/docs/architecture.md b/docs/architecture.md index 90d64fb0ac..ebf05397cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | | `ctx.agents` | `dsh-agent` | live agents, delegated creation, `agent/*` events, process-local initiator scope | -| `ctx.agentDefaultModel` | [`dsh-agent-default-model`](../packages/core/agent-default-model/README.md) | Settings-backed model selection shared by Agent front doors | +| `ctx.agentDefaultModel` | [`dsh-agent-default-model`](../packages/core/agent-default-model/README.md) | Settings-backed model selection shared by Agent entry points | | `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver | ### Capability Services @@ -164,7 +164,7 @@ Exceptions combine LLM Service Definition/Consumer roles, filesystem policy, web ### Bundles And Apps -`dsh-agent-spine-demo` bundles a spine and optional goals. App packages own CLI, ACP automation, and JSON-RPC front doors ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [interaction/](../packages/interaction/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK defaults when config is absent ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles a spine and optional goals. App packages own CLI, ACP automation, and JSON-RPC entry points ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [interaction/](../packages/interaction/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK defaults when config is absent ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Agent Presets diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 05186f3e56..085d993fef 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 05788eb8044f91e31c82dc4b78af0421e2b11030 -capability-seams.zh.md: ae182cfeeb1d7461122d791eedb818160a772e9b +capability-seams.md: 345e17c8c28bbe3465abd20639770e6331a70d11 +capability-seams.zh.md: 472aaabf4c992fdcd54fbe0e9a14cf9a82e6b803 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 05788eb804..345e17c8c2 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -399,7 +399,7 @@ flowchart LR | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/self-modification/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | -| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | +| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | @@ -407,7 +407,7 @@ flowchart LR | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | -| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless), [`host-apiproxy`](../packages/host/apiproxy) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent front doors share one state owner. | +| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless), [`host-apiproxy`](../packages/host/apiproxy) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | Owns one shared E2B SDK handle, remote working directory, and final sandbox disposition so both fundamental E2B providers inhabit the same Linux runtime. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index ae182cfeeb..472aaabf4c 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -401,7 +401,7 @@ flowchart LR | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm)、[`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | - | - | 负责确定性回退、最新标题折叠区,以及唯一的可选异步提供方注册。 | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop)、[`tools`](../packages/core/tools)、[`tool-fs`](../packages/fs/tool-fs)、[`tool-pty`](../packages/pty/tool-pty)、[`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop)、[`tool-ask-user`](../packages/interaction/tool-ask-user)、[`tool-bash`](../packages/bash/tool-bash)、[`tool-cordis`](../packages/self-modification/tool-cordis)、[`tool-fs`](../packages/fs/tool-fs)、[`tool-pty`](../packages/pty/tool-pty)、[`tool-skill`](../packages/skill/tool-skill)、[`tool-subagent`](../packages/subagent/tool-subagent)、[`tool-todo`](../packages/todo/tool-todo)、[`tool-web`](../packages/web/tool-web) | - | 注册能力,负责 Code Mode 传输,并让调用依次经过策略前处理、单调守卫、环绕分派、策略后处理和最终结果观测。 | -| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 入口提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | +| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 前端提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 | @@ -409,7 +409,7 @@ flowchart LR | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge)、[`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop)、[`acp`](../packages/acp/acp)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 | -| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless)、[`host-apiproxy`](../packages/host/apiproxy) | - | 通过 settings 分层默认 `ModelSelection`,让直接前门与 Host 支撑的 Agent 前门共享同一个状态所有者。 | +| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless)、[`host-apiproxy`](../packages/host/apiproxy) | - | 通过 settings 分层默认 `ModelSelection`,让直接入口与 Host 支撑的 Agent 入口共享同一个状态所有者。 | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | 从会话日志折叠带修订版本的目标状态,并将实时延续激活保留在进程本地。 | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b)、[`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | 拥有一个共享的 E2B SDK 句柄、远程工作目录和最终沙箱处置,使两个基础 E2B 提供方处于同一个 Linux 运行时中。 | diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml index fd29baab75..8fb1893fca 100644 --- a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml +++ b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/07-into-the-harness.md -07-into-the-harness.md: 38483b5c4993a44562970782dca5f676e4cb84f6 -07-into-the-harness.zh.md: 59ce716bdace894682bc1c8e6e00cf174008c26c +07-into-the-harness.md: 69133786f58541b015aed080f4ac8fb2a7e488c0 +07-into-the-harness.zh.md: bc9c61da984e3eb691eb6bfbe59ae556823e82de diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index 38483b5c49..69133786f5 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -95,7 +95,7 @@ The logger fired first: `tools/result` is emitted as part of result materializat ## From here to a full agent -A real agent is this composition plus more plugins: an LLM adapter, the agent loop, persistence, a front end. Compare [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml) — you can read every entry in it now. Add your `greet-tool.ts` to a copy of that file. +A real agent is this composition plus more plugins: an LLM adapter, the agent loop, persistence, an entry point. Compare [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml) — you can read every entry in it now. Add your `greet-tool.ts` to a copy of that file. Where to go next: diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md index 59ce716bda..bc9c61da98 100644 --- a/docs/cordis-tutorial/07-into-the-harness.zh.md +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -95,7 +95,7 @@ logger 会先触发:`tools/result` 在结果物化过程中发出,发生在 ## 从这里走向完整 agent(智能体) -真实 agent 就是这套组合再加上更多插件:LLM(大语言模型)适配器、agent loop(智能体循环)、持久化和前端。对照 [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml),你现在已经可以读懂其中每个配置项。将 `greet-tool.ts` 加入该文件的副本即可。 +真实 agent 就是这套组合再加上更多插件:LLM(大语言模型)适配器、agent loop(智能体循环)、持久化和运行入口。对照 [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml),你现在已经可以读懂其中每个配置项。将 `greet-tool.ts` 加入该文件的副本即可。 后续可以阅读: diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index e540ed7d90..0b2b87a954 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: 9a1fa827a7e0168e662bc4595a3c0fc486be8d49 -core.zh.md: e51f8b61fb6ed0c7a6e2de377f8f93877f972831 +core.md: af27484160769156836f377e5b3aba2521280005 +core.zh.md: 12935f4d881f371cfe2c3c5bed85ef88f57ec71a diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 9a1fa827a7..af27484160 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -330,7 +330,7 @@ currentSelection(): ModelSelection /** * Save the complete default model selection. A deployment without a settings * provider keeps its composition entry. - * @param next - resolved selection accepted by a front door. + * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise<void> diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index e51f8b61fb..12935f4d88 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -338,7 +338,7 @@ currentSelection(): ModelSelection /** * Save the complete default model selection. A deployment without a settings * provider keeps its composition entry. - * @param next - resolved selection accepted by a front door. + * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise<void> diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index f4a719fdb2..c95a0db6c0 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/providers.md -providers.md: 29575fcc860721d35588d82a15d9b97f1b7dea0e -providers.zh.md: 4b5fd32224fbfe5b685f886375b590fa3704505d +providers.md: 4667e54161e77a62d454f3f78a8164d5c79b78c8 +providers.zh.md: 15e0ccc826a5c285c71d4776793f8048c11f6254 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 29575fcc86..4667e54161 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -133,7 +133,7 @@ agent-default-model: reasoningEffort: high # optional ``` -After a session has run a turn, its own log remains authoritative for its model selection; the default applies only to sessions without a recorded request. The shipped fallback under this section is the base bundle's `agent-default-model` composition entry (`deepseek-official` / `deepseek-v4-flash`). A self-assembled `cordis.yml` mounts and configures `@deepseek-ai/dsh-agent-default-model`; both direct front doors and Host-backed front doors read that same service. +After a session has run a turn, its own log remains authoritative for its model selection; the default applies only to sessions without a recorded request. The shipped fallback under this section is the base bundle's `agent-default-model` composition entry (`deepseek-official` / `deepseek-v4-flash`). A self-assembled `cordis.yml` mounts and configures `@deepseek-ai/dsh-agent-default-model`; both direct entry points and Host-backed entry points read that same service. If the provider a saved default names is later removed, the composer says **Select model** and refuses input until you pick one, rather than sending to a route nothing serves. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 4b5fd32224..15e0ccc826 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -133,7 +133,7 @@ agent-default-model: reasoningEffort: high # optional ``` -会话跑过一轮后,其自身日志仍是模型选择的权威;默认值只适用于尚无请求记录的会话。这个段落之下的出厂兜底是 base 组合包的 `agent-default-model` 组合条目(`deepseek-official` / `deepseek-v4-flash`)。自行组装的 `cordis.yml` 会挂载并配置 `@deepseek-ai/dsh-agent-default-model`;直接前门与 Host 支撑的前门都读取同一服务。 +会话跑过一轮后,其自身日志仍是模型选择的权威;默认值只适用于尚无请求记录的会话。这个段落之下的出厂兜底是 base 组合包的 `agent-default-model` 组合条目(`deepseek-official` / `deepseek-v4-flash`)。自行组装的 `cordis.yml` 会挂载并配置 `@deepseek-ai/dsh-agent-default-model`;直接入口与 Host 支撑的入口都读取同一服务。 如果某个已存默认值指向的提供方后来被删掉了,输入框会显示**选择模型**并拒绝输入,而不是把消息发给一个没人服务的路由。 diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 5e6a21e1ec..49614b8c2d 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -24,7 +24,7 @@ flowchart LR cfg --> plugin_acp_acp_agent plugin_acp_acp_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] plugin_acp_acp_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_acp_acp_agent --> frontdoor_acp["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"] + plugin_acp_acp_agent --> entrypoint_acp["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] diff --git a/examples/headless-agent/README.i18n.yaml b/examples/headless-agent/README.i18n.yaml index 07a1dc582a..90dd1c4f2a 100644 --- a/examples/headless-agent/README.i18n.yaml +++ b/examples/headless-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/headless-agent/README.md -README.md: 6e80e56dec70c2be341ae5dfbad70e13a5109715 +README.md: f12a56920c79f3a7e257c4e56163f323e4312d11 README.zh.md: 9e409735f03afc62cd788fa2a5d1afdef0fa6c2a diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index 6e80e56dec..f12a56920c 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This directory owns the replay and real-model test composition for a headless coding agent: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence. It explicitly mounts the shared agent spine, one root agent, persistence, and checkpoint policy; it is not a second product front door. +This directory owns the replay and real-model test composition for a headless coding agent: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence. It explicitly mounts the shared agent spine, one root agent, persistence, and checkpoint policy; it is not a second product entry point. ## Run it diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index c512127199..977afa8863 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -65,7 +65,7 @@ - id: agent name: '@deepseek-ai/dsh-agent' - # The transport-independent default for Agents created by front doors. + # The transport-independent default for Agents created by entry points. # Settings may supply a saved selection; consumers read it at creation time. - id: agent-default-model name: '@deepseek-ai/dsh-agent-default-model' diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index c9d9e0b69c..e099db50fc 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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-skill/README.md -README.md: 677ac215d299fca695a6b27c564779ef1d3fd6ee -README.zh.md: 8f1f69b26a932aaa300bdda1d4ec7b2fa749fe3c +README.md: d8e88cb7b0215b06cd55a4ee9a7932ef180f572f +README.zh.md: 073a41cac95aeb96b658b075011d8212684f1c65 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 677ac215d2..d8e88cb7b0 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. -A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every front end, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. +A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every entry point, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 8f1f69b26a..073a41cac9 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -4,7 +4,7 @@ skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 -pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每一种前端注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 +pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每个入口注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 524cd180cc..2a2d67744e 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -8,7 +8,7 @@ * determinism * lives host-side — the pre-step boundary (`dsh-tool-skill`) recognizes a * leading `/name` naming a user-invocable skill and injects the rendered - * body for every front end, including `disable-model-invocation` skills the + * body for every entry point, including `disable-model-invocation` skills the * model-side catalog never lists (issue #1470). The RPC rides the plugin's * root-context connection captured at registration — the source never reads * services off a per-call argument. Draft chip visuals derive from @@ -167,7 +167,7 @@ export function apply(ctx: ClientContext): void { // lands plain text and the prompt ships the same // literal. Determinism lives host-side — the host's // pre-step boundary (dsh-tool-skill) recognizes the leading /name and - // injects the rendered body for every front end. A name shared with a + // injects the rendered body for every entry point. A name shared with a // host command still resolves to the command: adjudication claims the // line client-side before it ever becomes a prompt. return { text: `/${candidate.name} ` } diff --git a/packages/core/README.i18n.yaml b/packages/core/README.i18n.yaml index 4d9ab302d8..44e417c057 100644 --- a/packages/core/README.i18n.yaml +++ b/packages/core/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/README.md -README.md: 8349371ab565f2e9e735cd959026936c7ec44081 +README.md: 504686f8563f073fc8261c88275a5cdd172dd060 README.zh.md: e19c446584cfac75cb50833fa01586688b9c7c92 diff --git a/packages/core/README.md b/packages/core/README.md index 8349371ab5..504686f856 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -11,10 +11,10 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, deploy | [`system-prompt/`](system-prompt/README.md) | Prompt and tool-schema assembly registry | `ctx.systemPrompt` | | [`tools/`](tools/README.md) | Scoped tool registry and execution pipeline | `ctx.tools` | | [`agent/`](agent/README.md) | Agent interface, registry, and event vocabulary | `ctx.agents` | -| [`agent-default-model/`](agent-default-model/README.md) | Default model selection shared by Agent front doors | `ctx.agentDefaultModel` | +| [`agent-default-model/`](agent-default-model/README.md) | Default model selection shared by Agent entry points | `ctx.agentDefaultModel` | | [`agent-loop/`](agent-loop/README.md) | Default concrete agent driver | `ctx.agentLoop` | -`scope` supplies the shared scoping primitive. `agent` owns the public contract, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable. `agent-default-model` owns the deployment selection an Agent front door uses only when a session has no selection of its own. +`scope` supplies the shared scoping primitive. `agent` owns the public contract, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable. `agent-default-model` owns the deployment selection an Agent entry point uses only when a session has no selection of its own. Runnable compositions belong to [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md); this group owns only the swappable spine pieces. diff --git a/packages/core/agent-default-model/README.i18n.yaml b/packages/core/agent-default-model/README.i18n.yaml index 92c6095788..7835a159bc 100644 --- a/packages/core/agent-default-model/README.i18n.yaml +++ b/packages/core/agent-default-model/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-default-model/README.md -README.md: 02bcc9be3adee2293a20b3ae87ddaf4d52e70deb +README.md: 98bc7d082e62a764868f8acd323c4617e9839e61 README.zh.md: 807b612bd25e49aa318c13c8c8dc7595a6459080 diff --git a/packages/core/agent-default-model/README.md b/packages/core/agent-default-model/README.md index 02bcc9be3a..98bc7d082e 100644 --- a/packages/core/agent-default-model/README.md +++ b/packages/core/agent-default-model/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The deployment default used when a front door creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct front doors such as `dsh run` and Host-backed front doors such as ApiProxy read the same service instead of owning parallel provider/model defaults. +The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh run` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults. The plugin config requires `{ provider, model }`. That composition entry is the base of the `agent-default-model` Settings section; a mounted settings provider layers the user's choice over it and changes are visible on the next `currentSelection()` read. `reasoningEffort` belongs to the Settings section but deliberately not to plugin config: a complete saved selection can clear an effort when the next selected model has none, while a composition value would be inherited again. @@ -13,7 +13,7 @@ The service does not validate catalog membership. A provider route may serve an ## Model Experience -Indirectly, through the provider/model selection supplied to a front door; request assembly and adapters own the model-visible request. +Indirectly, through the provider/model selection supplied to an entry point; request assembly and adapters own the model-visible request. #### KV Cache effect @@ -21,5 +21,5 @@ Changing the default affects only Agents that subsequently resolve from it. An e ## Known Limitations and Deferred Work -- The service owns one process-wide default; per-session selection remains the front door's responsibility. +- The service owns one process-wide default; per-session selection remains the entry point's responsibility. - Without a settings provider, `saveSelection()` cannot retain a selection for a later Agent. diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index d012fbea93..0035b0b617 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-default-model", - "description": "Default model selection shared by Agent front doors", + "description": "Default model selection shared by Agent entry points", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/core/agent-default-model/src/index.ts b/packages/core/agent-default-model/src/index.ts index 36b3b9ba44..4d09b86eb3 100644 --- a/packages/core/agent-default-model/src/index.ts +++ b/packages/core/agent-default-model/src/index.ts @@ -92,7 +92,7 @@ export class AgentDefaultModelService extends Service { /** * Save the complete default model selection. A deployment without a settings * provider keeps its composition entry. - * @param next - resolved selection accepted by a front door. + * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise<void> { diff --git a/packages/core/agent/src/model-selection.ts b/packages/core/agent/src/model-selection.ts index 4d36ca34fb..a49e2f5979 100644 --- a/packages/core/agent/src/model-selection.ts +++ b/packages/core/agent/src/model-selection.ts @@ -1,5 +1,5 @@ /** - * Agent-scoped model selection shared by interactive front doors. + * Agent-scoped model selection shared by runtime entry points. * @module @deepseek-ai/dsh-agent/model-selection */ @@ -33,7 +33,7 @@ export interface ModelSelectionRef { * the selected model's provider/default behavior. * * @param agentCtx - The selected Agent's scoped context. - * @param selection - Mutable selection owned by the calling front door. + * @param selection - Mutable selection owned by the calling entry point. * @returns Disposer for both scoped waterfall listeners. */ export function installModelSelection(agentCtx: Context, selection: ModelSelectionRef): () => void { diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index e77a9283b2..2270947eea 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/README.md -README.md: 2d672dcc307bb280cf3803f29128eba4988a8da0 -README.zh.md: 24f64096dda0ccdac51afb90754ae25950750b89 +README.md: d8369b1e263e72c7b0ac1687c3b14a5d723ab944 +README.zh.md: acb402e925f692beaacbe0ab4e029691d664dbe8 diff --git a/packages/examples/README.md b/packages/examples/README.md index 2d672dcc30..d8369b1e26 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling the spine and a front door by hand. These are **demo / reference** packages — the `-demo` npm suffix marks each one as non-product surface, readable straight off the package name. The runnable leaves under the repo-root [`examples/`](../../examples/AGENTS.md) and the [Python SDK runtime](../../python/sdk-runtime/README.md) are the consumers; each is just its swappable backends plus one bundle entry. +Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling the spine and an entry point by hand. These are **demo / reference** packages — the `-demo` npm suffix marks each one as non-product surface, readable straight off the package name. The runnable leaves under the repo-root [`examples/`](../../examples/AGENTS.md) and the [Python SDK runtime](../../python/sdk-runtime/README.md) are the consumers; each is just its swappable backends plus one bundle entry. | Package | npm name | Role | |---|---|---| @@ -10,8 +10,8 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP automation application bundle | | [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | External-config JSON-RPC runtime | -`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation front door, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it. +`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation entry point, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it. -These packages are not product API. Product seams and front doors remain in their owning groups; demo bundles select concrete compositions. +These packages are not product API. Product seams and entry points remain in their owning groups; demo bundles select concrete compositions. Do not confuse this group with the repo-root [`examples/`](../../examples/AGENTS.md): that directory holds the runnable `cordis.yml` **leaves**; this group holds the **bundles** those leaves load. diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index 24f64096dd..acb402e925 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和前端入口。这些是 **演示/参考** 包;npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK runtime](../../python/sdk-runtime/README.md) 是消费方;每个叶节点都只包含可替换后端和一个组合包入口。 +预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和运行入口。这些是 **演示/参考** 包;npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK runtime](../../python/sdk-runtime/README.md) 是消费方;每个叶节点都只包含可替换后端和一个组合包入口。 | 包 | npm 名称 | 角色 | |---|---|---| @@ -12,6 +12,6 @@ `agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行由 `dsh run` 提供;本目录没有任何包提供该功能。 -这些包不是产品 API。产品 seam 与前端入口仍位于各自的归属组;演示组合包选择具体组合。 +这些包不是产品 API。产品 seam 与产品入口仍位于各自的归属组;演示组合包选择具体组合。 不要将此组与仓库根目录的 [`examples/`](../../examples/AGENTS.md) 混淆:该目录存放可运行的 `cordis.yml` **叶节点**;此组存放这些叶节点加载的 **组合包**。 diff --git a/packages/examples/acp-demo/README.i18n.yaml b/packages/examples/acp-demo/README.i18n.yaml index ff17cf5bb7..af16e2eacd 100644 --- a/packages/examples/acp-demo/README.i18n.yaml +++ b/packages/examples/acp-demo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/acp-demo/README.md -README.md: 395ab230146568989c4e6d1361218efb72d857e7 -README.zh.md: 667fc1a794eba15c7754ad9887f8083d3643f3e6 +README.md: edc45c9857a631cef72eb41b1a98c390f112291e +README.zh.md: c2946aa3d1feaed558408cf0921e2480c031187d diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 395ab23014..edc45c9857 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -55,4 +55,4 @@ Append-only per session; the app adds no request-prefix content itself. - **JSONL persistence is fixed** — a different backend requires another composition. - **Sibling plugins can corrupt stdout** — the app cannot prevent another entry from writing non-protocol bytes. -- **Fresh automation sessions only** — resume and human interaction belong to other front doors. +- **Fresh automation sessions only** — resume and human interaction belong to other entry points. diff --git a/packages/examples/acp-demo/README.zh.md b/packages/examples/acp-demo/README.zh.md index 667fc1a794..c2946aa3d1 100644 --- a/packages/examples/acp-demo/README.zh.md +++ b/packages/examples/acp-demo/README.zh.md @@ -55,4 +55,4 @@ ACP(Agent Client Protocol)自动化服务器应用:默认 agent(智能 - **JSONL 持久化固定不变**:使用其他后端需要另一种组合。 - **同级插件可能破坏 stdout**:应用无法阻止另一个条目写入非协议字节。 -- **只支持新建自动化会话**:恢复和人工交互属于其他前端入口。 +- **只支持新建自动化会话**:恢复和人工交互属于其他运行入口。 diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 68a2909836..8a900dd3cd 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -71,7 +71,7 @@ export interface Config { goals?: agentCore.GoalConfig | false } -// Each front door owns a complete, directly readable config schema; extracting +// Each entry point owns a complete, directly readable config schema; extracting // the common fields would make two small app contracts depend on a new facade. /* jscpd:ignore-start */ export const Config: z<Config> = z.object({ @@ -114,7 +114,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> { const spine = ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }) await spine yield spine.dispose - // Same rationale as the Config schema above: each front door forwards its own + // Same rationale as the Config schema above: each entry point forwards its own // persistence passthroughs rather than sharing a facade with stdio-demo. /* jscpd:ignore-start */ const persistence = ctx.plugin(SessionPersistenceJsonl, { diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index a698160634..44de45d88f 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/agent-spine-demo/README.md -README.md: cfec2c46ada8ed97aef44fb1d4145ddecdeecab1 -README.zh.md: d482ea9ca7034874383472050a8c837bea5bfaad +README.md: cf0dc2ecd6e51eb872be75dfe6d80a5338605195 +README.zh.md: e5a8672d494e0c456aa820641e685d00be624445 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index cfec2c46ad..cf0dc2ecd6 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends. +The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only an entry point and the swappable backends. Read this package for the whole plugin tree and its composition order. @@ -41,15 +41,15 @@ Read this package for the whole plugin tree and its composition order. ## What it deliberately leaves OUTSIDE the bundle -The spine is everything COMMON to every front door. The swappable and front-door-coupled pieces stay out, picked by whatever loads the bundle: +The spine is everything COMMON to every entry point. The swappable and entry-point-coupled pieces stay out, picked by whatever loads the bundle: - **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`). - **model-backed session-title providers** — the bundle mounts the fallback service with overridable example limits (5 words, 40 fallback bytes, 80 accepted-title bytes); a leaf may opt into exactly one first-message or all-messages LLM provider. - **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl). - **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings. -- **front-door + per-app infrastructure** — headless, ACP, and JSON-RPC app packages own transport, stdout, and reload choices. `timer` stays in the spine because it is common and stdout-silent. +- **entry point + per-app infrastructure** — headless, ACP, and JSON-RPC app packages own transport, stdout, and reload choices. `timer` stays in the spine because it is common and stdout-silent. -This applies the [Service Definition / Service provider / Consumer separation](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) at the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door. +This applies the [Service Definition / Service provider / Consumer separation](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) at the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the entry point. ## Config @@ -65,9 +65,9 @@ For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/ ## Why a code bundle, not a shared YAML include -A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. The ACP app package makes protocol-pure stdout wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling. +A YAML include can deduplicate config but cannot own a bin or provide entry-point defaults. The ACP app package makes protocol-pure stdout wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling. -The retry policy may repeat a failed request in a new numbered step. Retry status, provider errors, and failed partial chunks stay outside model history; each provider attempt can still incur billing, always mode has no attempt limit, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. +The retry policy may repeat a failed request in a new numbered step. Retry status, provider errors, and failed partial chunks stay outside model history; each provider attempt can still incur billing, always mode has no attempt limit, entry points derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. ## Model Experience diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index d482ea9ca7..e5a8672d49 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -将 **默认的不含执行器、不含 UI 的 agent(智能体)主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill(技能)提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包(package)只需添加前端入口和可替换后端,就能组合出可工作的 agent。 +将 **默认的不含执行器、不含 UI 的 agent(智能体)主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill(技能)提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包(package)只需添加入口和可替换后端,就能组合出可工作的 agent。 阅读此包可了解完整插件树及其组合顺序。 @@ -41,15 +41,15 @@ ## 有意留在组合包外的组件 -主干包含每个前端入口都共有的全部组件。可替换组件和与前端入口耦合的组件留在外部,由加载组合包的一方选择: +主干包含每个入口都共有的全部组件。可替换组件和与入口耦合的组件留在外部,由加载组合包的一方选择: - **LLM(大语言模型)适配器**:组合包交付抽象 `llm` 服务;叶节点在 `ctx.llm` 上注册具体适配器(`llm-deepseek`、`llm-pi-ai`、`llm-replay`)。 - **基于模型的会话标题提供方**:组合包挂载带可覆盖示例限制的后备服务(5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。 - **bash 执行器**:组合包交付 `tool-bash`(消费方 schema);叶节点提供 `ctx.bash`(`bash-local` 或沙箱化实现)。 - **非本地 skill 提供方**:组合包交付 skill 注册表、本地文件系统提供方和 `skill` 工具;部署可以把嵌入式目录或远程目录等其他提供方作为同级插件添加。 -- **前端入口与各应用基础设施**:无头、ACP(Agent Client Protocol)和 JSON-RPC 应用包负责传输、stdout 与重新加载选择。`timer` 保留在主干中,因为它是共有组件且不写 stdout。 +- **入口与各应用基础设施**:无头、ACP(Agent Client Protocol)和 JSON-RPC 应用包负责传输、stdout 与重新加载选择。`timer` 保留在主干中,因为它是共有组件且不写 stdout。 -这里在组合层应用 [Service Definition/Service provider/Consumer 的职责分离](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):组合包拥有共享主干,叶节点拥有后端,应用包拥有前端入口。 +这里在组合层应用 [Service Definition/Service provider/Consumer 的职责分离](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):组合包拥有共享主干,叶节点拥有后端,应用包拥有入口。 ## 配置 @@ -65,9 +65,9 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' ## 为何使用代码组合包,而非共享 YAML include -YAML include 可以去重配置,却无法拥有 bin 或提供前端入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此叶节点的同级插件无需依赖加载顺序即可通过注入看到它们。 +YAML include 可以去重配置,却无法拥有 bin 或提供入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此叶节点的同级插件无需依赖加载顺序即可通过注入看到它们。 -重试策略可能在新的编号步骤中重复失败的请求。重试状态、提供方错误和失败的部分分片不进入模型历史;每次提供方尝试仍可能产生计费;always 模式没有尝试次数上限;前端入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方缓存。 +重试策略可能在新的编号步骤中重复失败的请求。重试状态、提供方错误和失败的部分分片不进入模型历史;每次提供方尝试仍可能产生计费;always 模式没有尝试次数上限;入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方缓存。 ## 模型体验 diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 30c51fc941..7d9e99a908 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -165,7 +165,7 @@ export const Config = z.intersect([ ]) as unknown as z<Config> /** - * Copy the bundle-owned fields from an app config without leaking front-door settings. + * Copy the bundle-owned fields from an app config without leaking entry-point settings. * @param config - App config containing the shared spine fields. * @returns The fields accepted by this bundle, preserving optional absence. */ diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 8ffd971829..8de047a79a 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -236,7 +236,7 @@ describe('dsh-agent-spine-demo bundle', () => { } }) - it('loads and configures bounded request recovery for every bundled front door', async () => { + it('loads and configures bounded request recovery for every bundled entry point', async () => { const adapter = new TransientOnceAdapter() const ctx = await mount({ workspaceContext: false }) ctx.llm.registerAdapter(['mock'], adapter) @@ -704,9 +704,9 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) - it('picks shared spine config without leaking front-door fields', () => { + it('picks shared spine config without leaking entry-point fields', () => { const appConfig = { - model: 'front-door-only', + model: 'entrypoint-only', includeHarnessIdentity: false, persona: 'You are merged.', toolOrder: ['zulu'], diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index bc4a93d760..d919320643 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/feedback/command-feedback/README.md -README.md: e2eb6d4cf2b40e83efad1fa158edd72578658f56 +README.md: d7849e25fc62897e4ac6793f40bdc139adf9ba3d README.zh.md: c3b7b59d90d924de6042aeac1e7eec39457c6c83 diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index e2eb6d4cf2..d7849e25fc 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -56,4 +56,4 @@ Independent of the model request path. Recording appends to the session log only - **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text. - **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one. - **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`. -- **Web only in the shipped front doors** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. +- **Web only among the shipped entry points** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 30f7a65c51..cb86d86c92 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -143,7 +143,7 @@ export function apply(ctx: Context, config: Config): void { const groups: MatcherGroup[] = parsed[point] ?? [] const outputs: HookOutput[] = [] // Run the hook in the agent's session workspace (the `session/new` cwd on the session - // header), not the executor or front-door process's launch dir. + // header), not the executor or entry-point process's launch dir. const workdir = opts.agent?.session.header.cwd // CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session // workspace (the same dir the hook runs in). diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index f449e05a28..cba01a89bf 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: 98fcdda155286feb23aaf294cab76529ce31cfc1 -README.zh.md: 8cfa7e527a327d9c342a5cf6cf28163f5b45df1c +README.md: c29f30b85c5579f278ac9b40a0422347502eeb8f +README.zh.md: 92b866bafd71902c55bf0bad14c6b9e761421cf8 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 98fcdda155..c29f30b85c 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -50,13 +50,13 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse `agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) -`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core front door and does not mount this package. +`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core entry point and does not mount this package. ## Model Experience diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 8cfa7e527a..92b866bafd 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -50,7 +50,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此每一种前端(web、TUI、ACP(Agent Client Protocol)、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index bd060e7d19..3e5157ca90 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -8,7 +8,7 @@ * routes — physical carriers wrap `ctx.apiProxy` themselves. * * The gateway consumes `ctx.agentDefaultModel`, the transport-independent default - * shared with direct front doors. Switching models persists through that + * shared with direct entry points. Switching models persists through that * service; sessions that have already logged a selection remain unchanged. */ diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index 7d4217d3d4..2a9323474e 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/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/plan/plan-mode/README.md -README.md: 6c8ba23b76e83665d4f8dcb5ecb41689347f6423 +README.md: c404cfa73024804bc9f166cfb84fa5f87f723459 README.zh.md: 275a87669802f38cd98886236ca63a09ffb3e410 diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 6c8ba23b76..c404cfa730 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -18,7 +18,7 @@ The review question declares the `plan-review` presentation intent, naming `Appr When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. -The Web client consumes the plugin-owned `/plan` command; other front doors may drive the same service directly without defining a second mode vocabulary. +The Web client consumes the plugin-owned `/plan` command; other entry points may drive the same service directly without defining a second mode vocabulary. ## Session projection diff --git a/packages/scaffold/helper/src/features/builtin/app.ts b/packages/scaffold/helper/src/features/builtin/app.ts index 106cf07097..1695accf56 100644 --- a/packages/scaffold/helper/src/features/builtin/app.ts +++ b/packages/scaffold/helper/src/features/builtin/app.ts @@ -50,7 +50,7 @@ class AppOption extends FeatureOption { this.label = label } - /** Identify options by their unique front door, not the shared interaction service. */ + /** Identify external options by their run interface, not the shared interaction service. */ override markerConfigEntries(): readonly { id: string; name: string }[] { switch (this.id) { case 'acp': return [{ id: 'acp', name: '@deepseek-ai/dsh-acp' }] @@ -58,7 +58,7 @@ class AppOption extends FeatureOption { } } - /** Embed is identified by the configured loop with no external front door. */ + /** Embed is identified by the configured loop and absence of an external entry point. */ override matchesConfigEntries(entries: readonly { id: string; name: string }[], profile: ProjectProfile): boolean { if (this.id !== 'embed') return super.matchesConfigEntries(entries, profile) return entries.some(entry => entry.id === 'agent-loop' && entry.name === '@deepseek-ai/dsh-agent-loop') @@ -93,7 +93,7 @@ export class AppFeature extends ExclusiveOptionFeature { new AppOption('embed', 'Embedded context'), ] - /** Default to the profile's already selected front door. */ + /** Default to the profile's already selected run interface. */ override defaultOptions(profile: ProjectProfile): readonly string[] { return [profile.runInterface] } diff --git a/packages/scaffold/helper/src/features/feature.ts b/packages/scaffold/helper/src/features/feature.ts index 5eb3d2bc5a..93d51c5ffe 100644 --- a/packages/scaffold/helper/src/features/feature.ts +++ b/packages/scaffold/helper/src/features/feature.ts @@ -112,7 +112,7 @@ export abstract class Feature { readonly requires: readonly FeatureId[] = [] /** Features recommended during creation. */ readonly suggests: readonly FeatureId[] = [] - /** Front doors under which this feature is meaningful. */ + /** Run interfaces under which this feature is meaningful. */ readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'embed'] /** @@ -141,7 +141,7 @@ export abstract class Feature { } /** - * Whether the feature may be selected for this project front door. + * Whether the feature may be selected for this project run interface. * @param profile - project context to check. * @returns whether the feature applies. */ diff --git a/packages/scaffold/helper/src/project/types.ts b/packages/scaffold/helper/src/project/types.ts index 0c66bb5ac2..72f4259e88 100644 --- a/packages/scaffold/helper/src/project/types.ts +++ b/packages/scaffold/helper/src/project/types.ts @@ -8,7 +8,7 @@ import type { PackageManager } from '../package-managers/package-manager.ts' import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts' import type { FeatureId } from '../ids.ts' -/** Runtime front door selected for a generated project. */ +/** Run interface selected for a generated project. */ export type RunInterface = 'acp' | 'embed' /** Values shared by the required provider and app features. */ diff --git a/packages/scaffold/helper/tests/project.spec.ts b/packages/scaffold/helper/tests/project.spec.ts index d0f275ca4c..41acf530ba 100644 --- a/packages/scaffold/helper/tests/project.spec.ts +++ b/packages/scaffold/helper/tests/project.spec.ts @@ -209,7 +209,7 @@ describe('SdkProject and ProjectEditSession', () => { expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-tasks') }) - it('round-trips embed app projects without a front-door Cordis config entry', async () => { + it('round-trips embed app projects without an ACP Cordis config entry', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-embed-app-')) temporary.push(root) const creation = request([], [], 'embed') diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 0d984e79e1..86def75601 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -72,7 +72,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async saveSelection(next: ModelSelection): Promise<void>', - jsDoc: '/**\n * Save the complete default model selection. A deployment without a settings\n * provider keeps its composition entry.\n * @param next - resolved selection accepted by a front door.\n * @returns fulfillment after the optional settings write settles.\n */', + jsDoc: '/**\n * Save the complete default model selection. A deployment without a settings\n * provider keeps its composition entry.\n * @param next - resolved selection accepted by an entry point.\n * @returns fulfillment after the optional settings write settles.\n */', }, ], }, diff --git a/packages/self-modification/tool-cordis/src/sandbox.ts b/packages/self-modification/tool-cordis/src/sandbox.ts index 3c99c7a770..6b3e20a82c 100644 --- a/packages/self-modification/tool-cordis/src/sandbox.ts +++ b/packages/self-modification/tool-cordis/src/sandbox.ts @@ -15,7 +15,7 @@ import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts' * A write-through console for one sandbox, tagging every line with the mount * id. Write-through (host stdout/stderr), NOT buffered into the tool result: * a mounted listener fires long after the mount call returned, and its output - * must land somewhere the user can see — for a terminal front door, the host terminal. + * must land somewhere the user can see — for a terminal entry point, the host terminal. */ function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | 'debug', (...args: unknown[]) => void> { const tag = `[cordis:${id}]` diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 59a8e0200f..26e9233f82 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -258,7 +258,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Human question/answer seam', mode: 'seam', consumers: ['tool-ask-user'], - note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', + note: 'UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', }, { key: 'planMode', @@ -320,7 +320,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Default Agent model selection', mode: 'core', consumers: ['headless', 'host-apiproxy'], - note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent front doors share one state owner.', + note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner.', }, { key: 'agentLoop', @@ -682,7 +682,7 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`) lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`) if (pluginName === '@deepseek-ai/dsh-acp-demo') { - lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"]`) + lines.push(` ${appNode} --> ${nodeId('entrypoint', 'acp')}["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"]`) } lines.push( ` ${agentCore} --> ${nodeId('spine', 'llm')}["ctx.llm"]`, From 7ba4244d6671d5b7b2fbcb5904dab70a1e3dc5a8 Mon Sep 17 00:00:00 2001 From: pku-xht <xht@deepseek.com> Date: Mon, 10 Aug 2026 13:11:22 +0800 Subject: [PATCH 066/229] fix(subagent): retain cancellation lint rationale --- packages/subagent/subagent-claude-code/src/run.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index f4600f4bc9..6c1e0a8dbf 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -258,6 +258,7 @@ export async function startClaudeCodeRun( ) } } + // oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited. if (cancelledBeforeCleanup || request.signal.aborted) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } From ec310e60f81599b8b67c28544b047c2aa9c541de Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Mon, 10 Aug 2026 13:12:35 +0800 Subject: [PATCH 067/229] test(web): align steer-all snapshots after master sync --- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 6 ++++-- apps/web/tests/snapshots/steer-all/settled.expected.md | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 998ee98129..8b77a77a0c 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -17,10 +19,10 @@ - img - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. - status: Deep diving... -- text: "Interjection Interjection: include the word BANANA in your final reply." +- text: "Interjection: include the word BANANA in your final reply." - button "Copy": - img -- text: "Interjection Interjection: include the word ORANGE in your final reply." +- text: "Interjection: include the word ORANGE in your final reply." - button "Copy": - img - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index a61f57572e..0899529a09 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -19,10 +21,10 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" +- text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img -- text: "Interjection Interjection: include the word ORANGE in your final reply. {{clock}}" +- text: "Interjection: include the word ORANGE in your final reply. {{clock}}" - button "Copy": - img - paragraph: "Got it: BANANA and ORANGE." From aee58c73e0ea69c6019283744bf03ec68aa06432 Mon Sep 17 00:00:00 2001 From: pku-xht <xht@deepseek.com> Date: Mon, 10 Aug 2026 13:29:23 +0800 Subject: [PATCH 068/229] test(subagent): compare Windows Claude paths case-insensitively --- .../subagent-claude-code/tests/real-product.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 213166861b..6048c930de 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -224,7 +224,12 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { message.type === 'system' && message.subtype === 'init', ) expect(initMessage?.claude_code_version).toBe('2.1.220') - expect(harness.spawnSpecs[0]?.argv[0]).toBe(harness.executable) + const spawnedExecutable = harness.spawnSpecs[0]?.argv[0] + expect(spawnedExecutable).toBeDefined() + if (spawnedExecutable !== undefined) { + expect(process.platform === 'win32' ? spawnedExecutable.toLowerCase() : spawnedExecutable) + .toBe(process.platform === 'win32' ? harness.executable.toLowerCase() : harness.executable) + } expect(fixture.requests).toHaveLength(1) const recorded = fixture.requests[0]! From 9f6ec0ead6e21f368c8ff08d048ae2f73505f326 Mon Sep 17 00:00:00 2001 From: pku-xht <xht@deepseek.com> Date: Mon, 10 Aug 2026 13:34:10 +0800 Subject: [PATCH 069/229] test(subagent): exercise native Windows Claude shim --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +-- ...claude-code-and-codex-subagent-backends.md | 4 --- ...ude-code-and-codex-subagent-backends.zh.md | 4 --- apps/cli/tests/web-agent-presets.e2e.ts | 18 ------------- .../tests/real-product.spec.ts | 25 +++++++++++-------- 5 files changed, 16 insertions(+), 39 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 2a66f8d5e4..8c39ed7525 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 783a8a6537d2b05ab99f1962bda88787e2e4e938 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: f331dc698001e24df62733d219f28b59d2358fac +2026-08-04-claude-code-and-codex-subagent-backends.md: eb4c4ab0116cdf1e8b9e6dd53655035a78afdfa0 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: c14b7977a267c2b7325e4a4382593c8f30dddea4 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 783a8a6537..eb4c4ab011 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -79,10 +79,6 @@ The project owner's distribution authorization is scoped to the official `@anthr **A model-visible product selector.** Product availability and authentication are deployment facts. Two fixed tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service. -**Global product enable settings and a product-specific Web page.** Those controls make Codex and Claude Code exceptions to the Agent Preset composition that already owns one agent's tool set, and one process-wide choice cannot represent two sessions using different presets. The host always supplies the providers; the preset alone decides which fixed tools its agent receives. - -**One shipped preset per product combination.** Four preset variants encode a two-boolean choice in preset identities and multiply every future standard-preset change. Independent ordinary rows express the same result in the user's copied preset without adding a roster taxonomy. - **Product doubles as required evidence.** Doubles cover exhaustive private protocol branches but do not prove package exports, official distributions, authentication, or real process behavior. Required evidence drives each official product against a loopback model fixture. **Plugin-managed login, product home, models, settings, or permissions.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. The providers expose only an explicit environment overlay and teardown grace; unattended interaction fails closed. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index f331dc6980..c14b7977a2 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -79,10 +79,6 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 **面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 -**全局产品启用设置与产品专属 Web 页面。** 这类控制会让 Codex 与 Claude Code 成为 Agent Preset 组装的例外,而后者本就拥有单个 agent 的工具集;一个进程级选择也无法表达两个会话使用不同 preset。宿主始终提供 provider,只有 preset 决定其 agent 获得哪些固定工具。 - -**为每种产品组合交付一份 preset。** 四个 preset 变体把两个布尔选择编码成 preset 身份,并让未来每次标准 preset 修改都要同步多份副本。用户复制的 preset 中两条独立普通行已经能表达同一结果,无需新增名单分类。 - **以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。 **由插件管理登录、产品主目录、模型、设置或权限。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 8f54d6ddf6..4e0dcc3a1d 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -132,24 +132,6 @@ describe('the shipped Web composition', () => { expect(ctx.agentPresets.defaultId).toBe('standard') }) - it('keeps product providers on the host while shipped presets leave their tools disabled', async () => { - expect(ctx.subagents.list()).toEqual(expect.arrayContaining([ - 'spawn', 'fork', 'codex', 'claude-code', - ])) - - const handle = await ctx.agents.create({ - sessionId: SessionId('preset-products-disabled'), - setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), - }) - try { - expect(toolNames(ctx, handle.agent)).not.toEqual(expect.arrayContaining([ - 'subagent_codex', 'subagent_claude_code', - ])) - } finally { - await handle.dispose() - } - }) - it('composes the full agent from `standard`', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-standard'), diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 6048c930de..d7555b9ed8 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -1,6 +1,5 @@ import { execFile } from 'node:child_process' import { - copyFileSync, mkdirSync, mkdtempSync, readFileSync, @@ -116,14 +115,17 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ const workspace = join(root, 'workspace') const claudeConfig = join(root, 'claude-config') const xdgConfig = join(root, 'xdg') - const nativeBin = join(root, 'native-bin') + const nativeBin = join(root, 'native bin') mkdirSync(workspace) mkdirSync(claudeConfig) mkdirSync(xdgConfig) mkdirSync(nativeBin) - const executable = join(nativeBin, process.platform === 'win32' ? 'claude.exe' : 'claude') - if (process.platform === 'win32') copyFileSync(claudeBin, executable) - else symlinkSync(claudeBin, executable) + const executable = join(nativeBin, process.platform === 'win32' ? 'claude.cmd' : 'claude') + if (process.platform === 'win32') { + writeFileSync(executable, `@echo off\r\n"${claudeBin}" %*\r\n`) + } else { + symlinkSync(claudeBin, executable) + } writeFileSync( join(claudeConfig, 'settings.json'), `${JSON.stringify({ model: settingsModel }, null, 2)}\n`, @@ -207,7 +209,7 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { expect(sdkPackage.version).toBe('0.3.220') expect(sdkPackage.claudeCodeVersion).toBe('2.1.220') expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220') - const version = await execFileAsync(harness.executable, ['--version'], { + const version = await execFileAsync(process.platform === 'win32' ? claudeBin : harness.executable, ['--version'], { env: { ...process.env, ...harness.env }, }) expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)') @@ -224,11 +226,12 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { message.type === 'system' && message.subtype === 'init', ) expect(initMessage?.claude_code_version).toBe('2.1.220') - const spawnedExecutable = harness.spawnSpecs[0]?.argv[0] - expect(spawnedExecutable).toBeDefined() - if (spawnedExecutable !== undefined) { - expect(process.platform === 'win32' ? spawnedExecutable.toLowerCase() : spawnedExecutable) - .toBe(process.platform === 'win32' ? harness.executable.toLowerCase() : harness.executable) + if (process.platform === 'win32') { + expect(harness.spawnSpecs[0]?.argv.slice(0, 5)).toEqual([ + 'cmd.exe', '/d', '/s', '/c', harness.executable, + ]) + } else { + expect(harness.spawnSpecs[0]?.argv[0]).toBe(harness.executable) } expect(fixture.requests).toHaveLength(1) From af1894eaef9f13e7a57d07c60e92701d469f706c Mon Sep 17 00:00:00 2001 From: pku-xht <xht@deepseek.com> Date: Mon, 10 Aug 2026 13:40:29 +0800 Subject: [PATCH 070/229] test(subagent): cover cmd metacharacter install paths --- .../subagent/subagent-claude-code/tests/real-product.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index d7555b9ed8..504d5a5056 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -115,7 +115,7 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ const workspace = join(root, 'workspace') const claudeConfig = join(root, 'claude-config') const xdgConfig = join(root, 'xdg') - const nativeBin = join(root, 'native bin') + const nativeBin = join(root, 'native&bin') mkdirSync(workspace) mkdirSync(claudeConfig) mkdirSync(xdgConfig) From bec5721f0cac545d8a7977000a6549fb9a6bb53c Mon Sep 17 00:00:00 2001 From: pku-xht <xht@deepseek.com> Date: Mon, 10 Aug 2026 13:55:24 +0800 Subject: [PATCH 071/229] fix(subagent): quote Windows Claude batch paths --- ...-claude-code-and-codex-subagent-backends.i18n.yaml | 4 ++-- ...6-08-04-claude-code-and-codex-subagent-backends.md | 4 ++-- ...8-04-claude-code-and-codex-subagent-backends.zh.md | 4 ++-- .../subagent/subagent-claude-code/README.i18n.yaml | 4 ++-- packages/subagent/subagent-claude-code/README.md | 2 +- packages/subagent/subagent-claude-code/README.zh.md | 2 +- packages/subagent/subagent-claude-code/src/process.ts | 11 ++++++++--- .../tests/subagent-claude-code.spec.ts | 5 ++++- 8 files changed, 22 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 8c39ed7525..776980f3fc 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: eb4c4ab0116cdf1e8b9e6dd53655035a78afdfa0 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: c14b7977a267c2b7325e4a4382593c8f30dddea4 +2026-08-04-claude-code-and-codex-subagent-backends.md: 5bdb041b90f11b13d92d3cbac250d614a51d3a5e +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: ad1ff7f32de56b07967163aba8d511145b551da4 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index eb4c4ab011..5bdb041b90 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -47,7 +47,7 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp ## Claude Code provider -`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. +`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. A Windows `.cmd` or `.bat` path crosses `cmd.exe` as a quoted per-spawn environment expansion, so path metacharacters remain data without changing the shared subprocess contract. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. The public configuration contains the same two deployment-owned values as the Codex sibling: an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Each run creates its own `AbortController`, sets `persistSession: false`, and disables `AskUserQuestion`. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK rather than waiting for a user interface the provider does not own. @@ -65,7 +65,7 @@ The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its rea The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220 and a native Claude Code installation compatible with its query protocol. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, and whole-tree exit. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. +The Claude Code evidence pins Agent SDK 0.3.220 and a native Claude Code installation compatible with its query protocol. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing a cmd metacharacter. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index c14b7977a2..ad1ff7f32d 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -47,7 +47,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 ## Claude Code 提供方 -`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 +`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。Windows `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境展开值穿过 `cmd.exe`,因此路径元字符仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 @@ -65,7 +65,7 @@ Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 Claude Code 安装。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消以及整棵进程树退出。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 +Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 Claude Code 安装。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于含 cmd 元字符路径中的真实 Windows batch shim。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 4f62110d49..0f8ec86943 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md -README.md: 5e3138b9211b01de9096fa1b8e8b68321aad0c7d -README.zh.md: cc536d13d58e08efc77f4f7b4374c8a1af8c5caa +README.md: 432a73474fee1c0cd3a3046247252a9b48cf865c +README.zh.md: 8af6f5050de44c01391662b4f92676711ef8892f diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 5e3138b921..432a73474f 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -29,7 +29,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | -Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. +Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe` expands once, so valid path metacharacters remain data while the SDK's fixed arguments stay ordinary argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. Shipped profiles load this provider once on the host and start no Claude process until a tool call. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. A custom host composition can still use both rows directly. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index cc536d13d5..8af6f5050d 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -29,7 +29,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 +生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe` 展开一次,因此合法路径中的元字符仍只是数据,而 SDK 的固定参数继续使用普通 argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Claude 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。自定义宿主组装仍可直接使用两条配置行。 diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 6200ac559b..4fc73cf6ae 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -17,6 +17,8 @@ import { type SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' +const WINDOWS_BATCH_EXECUTABLE_ENV = 'DSH_CLAUDE_CODE_EXECUTABLE' + function thrown(value: unknown): Error { /* v8 ignore next -- the subprocess seam rejects with Error. */ return value instanceof Error ? value : new Error(String(value)) @@ -53,16 +55,19 @@ export function claudeSpawnSpec( throw new Error('subagent-claude-code: SDK spawn request omitted its workspace') } const extension = extname(options.command).toLowerCase() - const argv = platform === 'win32' && (extension === '.cmd' || extension === '.bat') - ? ['cmd.exe', '/d', '/s', '/c', options.command, ...options.args] + const batchShim = platform === 'win32' && (extension === '.cmd' || extension === '.bat') + const env = sdkEnvironmentOverlay(options.env) + const argv = batchShim + ? ['cmd.exe', '/d', '/s', '/c', `%${WINDOWS_BATCH_EXECUTABLE_ENV}%`, ...options.args] : [options.command, ...options.args] + if (batchShim) env[WINDOWS_BATCH_EXECUTABLE_ENV] = `"${options.command}"` return { argv, cwd: options.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs, signal: options.signal, - env: sdkEnvironmentOverlay(options.env), + env, } } diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 423841a9d4..7e1bf7ea05 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -464,9 +464,12 @@ describe('official spawn projection', () => { }), 7, 'win32') expect(spec.argv).toEqual([ - 'cmd.exe', '/d', '/s', '/c', command, + 'cmd.exe', '/d', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', '--output-format', 'stream-json', ]) + expect(spec.env).toEqual(expect.objectContaining({ + DSH_CLAUDE_CODE_EXECUTABLE: `"${command}"`, + })) }) it('projects streams, exit facts, listeners, and idempotent tree termination', async () => { From d507894d43e5129db2681167207ad6de9b0a8058 Mon Sep 17 00:00:00 2001 From: pku-xht <xht@deepseek.com> Date: Mon, 10 Aug 2026 14:12:22 +0800 Subject: [PATCH 072/229] fix(subagent): disable delayed expansion for Claude shims --- ...8-04-claude-code-and-codex-subagent-backends.i18n.yaml | 4 ++-- .../2026-08-04-claude-code-and-codex-subagent-backends.md | 4 ++-- ...26-08-04-claude-code-and-codex-subagent-backends.zh.md | 4 ++-- packages/subagent/subagent-claude-code/README.i18n.yaml | 4 ++-- packages/subagent/subagent-claude-code/README.md | 2 +- packages/subagent/subagent-claude-code/README.zh.md | 2 +- packages/subagent/subagent-claude-code/src/process.ts | 2 +- .../subagent-claude-code/tests/real-product.spec.ts | 8 +++++--- .../tests/subagent-claude-code.spec.ts | 2 +- 9 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 776980f3fc..cb5c2c6e14 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 5bdb041b90f11b13d92d3cbac250d614a51d3a5e -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: ad1ff7f32de56b07967163aba8d511145b551da4 +2026-08-04-claude-code-and-codex-subagent-backends.md: 40c622d85b427e2e85c160eb956aeae7d384ca65 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 85e71edb9139764a244277f731849d14ab1230d7 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 5bdb041b90..40c622d85b 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -47,7 +47,7 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp ## Claude Code provider -`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. A Windows `.cmd` or `.bat` path crosses `cmd.exe` as a quoted per-spawn environment expansion, so path metacharacters remain data without changing the shared subprocess contract. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. +`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. A Windows `.cmd` or `.bat` path crosses `cmd.exe /v:off` as a quoted per-spawn environment expansion, so percent, ampersand, and exclamation path components remain data without changing the shared subprocess contract. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. The public configuration contains the same two deployment-owned values as the Codex sibling: an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Each run creates its own `AbortController`, sets `persistSession: false`, and disables `AskUserQuestion`. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK rather than waiting for a user interface the provider does not own. @@ -65,7 +65,7 @@ The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its rea The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220 and a native Claude Code installation compatible with its query protocol. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing a cmd metacharacter. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. +The Claude Code evidence pins Agent SDK 0.3.220 and a native Claude Code installation compatible with its query protocol. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index ad1ff7f32d..85e71edb91 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -47,7 +47,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 ## Claude Code 提供方 -`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。Windows `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境展开值穿过 `cmd.exe`,因此路径元字符仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 +`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。Windows `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境展开值穿过 `cmd.exe /v:off`,因此路径中的百分号、与号和感叹号仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 @@ -65,7 +65,7 @@ Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 Claude Code 安装。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于含 cmd 元字符路径中的真实 Windows batch shim。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 +Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 Claude Code 安装。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 0f8ec86943..05d8cd5705 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md -README.md: 432a73474fee1c0cd3a3046247252a9b48cf865c -README.zh.md: 8af6f5050de44c01391662b4f92676711ef8892f +README.md: 7b19dc8e8f0e4bf05097dae15c9455f30bc7c998 +README.zh.md: 0dff24816024c0c44b9bdb532b5c7a3e5656fb3b diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 432a73474f..7b19dc8e8f 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -29,7 +29,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | -Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe` expands once, so valid path metacharacters remain data while the SDK's fixed arguments stay ordinary argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. +Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data while the SDK's fixed arguments stay ordinary argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. Shipped profiles load this provider once on the host and start no Claude process until a tool call. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. A custom host composition can still use both rows directly. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 8af6f5050d..0dff248160 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -29,7 +29,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe` 展开一次,因此合法路径中的元字符仍只是数据,而 SDK 的固定参数继续使用普通 argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 +生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据,而 SDK 的固定参数继续使用普通 argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Claude 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。自定义宿主组装仍可直接使用两条配置行。 diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 4fc73cf6ae..03b9cfd2b3 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -58,7 +58,7 @@ export function claudeSpawnSpec( const batchShim = platform === 'win32' && (extension === '.cmd' || extension === '.bat') const env = sdkEnvironmentOverlay(options.env) const argv = batchShim - ? ['cmd.exe', '/d', '/s', '/c', `%${WINDOWS_BATCH_EXECUTABLE_ENV}%`, ...options.args] + ? ['cmd.exe', '/d', '/v:off', '/s', '/c', `%${WINDOWS_BATCH_EXECUTABLE_ENV}%`, ...options.args] : [options.command, ...options.args] if (batchShim) env[WINDOWS_BATCH_EXECUTABLE_ENV] = `"${options.command}"` return { diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 504d5a5056..7e1dae96d9 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -115,7 +115,7 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ const workspace = join(root, 'workspace') const claudeConfig = join(root, 'claude-config') const xdgConfig = join(root, 'xdg') - const nativeBin = join(root, 'native&bin') + const nativeBin = join(root, 'native&%literal%!bang!bin') mkdirSync(workspace) mkdirSync(claudeConfig) mkdirSync(xdgConfig) @@ -227,9 +227,11 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { ) expect(initMessage?.claude_code_version).toBe('2.1.220') if (process.platform === 'win32') { - expect(harness.spawnSpecs[0]?.argv.slice(0, 5)).toEqual([ - 'cmd.exe', '/d', '/s', '/c', harness.executable, + expect(harness.spawnSpecs[0]?.argv.slice(0, 6)).toEqual([ + 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', ]) + expect(harness.spawnSpecs[0]?.env?.DSH_CLAUDE_CODE_EXECUTABLE) + .toBe(`"${harness.executable}"`) } else { expect(harness.spawnSpecs[0]?.argv[0]).toBe(harness.executable) } diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 7e1bf7ea05..4fe2d709b1 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -464,7 +464,7 @@ describe('official spawn projection', () => { }), 7, 'win32') expect(spec.argv).toEqual([ - 'cmd.exe', '/d', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', + 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', '--output-format', 'stream-json', ]) expect(spec.env).toEqual(expect.objectContaining({ From 5d86a284e548ccfac0557cd2ea4ff106ac9e1306 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 14:18:41 +0800 Subject: [PATCH 073/229] fix(web-app,agent-presets): keep the task registry on the host plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tool-bash` resolves the background-task registry with `ctx.get('tasks')`, and it sits at the preset's top level. The registry sat inside an entry-local `isolate: { tasks: true }` realm, which is invisible to every sibling row outside it, while the Web surface disabled the host row — so both lookups missed and every `run_in_background` call answered "background tasks unavailable" with `task_output`, `task_list`, and `task_kill` still listed in the catalog. `task_list` returning "(no background tasks)" is what made the outage read as an empty queue rather than a severed producer. That is the `goals` criterion read from inside the preset: a Service a row outside its realm READS belongs to the plane both can see. `tasks` already keys access by owning agent (`assertAccess` compares `task.owner.id`) and mints an independent token per `attachSurface` call, so one host instance serves every session exactly as before presets — the per-preset-standing-mounts note records that sharing `tasks-local` is a return to its design. `minimal` mounts no `tool-tasks`, and the `start()` control-surface gate is a service-wide set that another preset's controls would open for it, so its `tool-bash` disables `run_in_background` and drops the parameter from the schema. Fixes #2141 --- .../agent-presets/code/agent.cordis.yml | 20 +++--- .../agent-presets/cordis/agent.cordis.yml | 20 +++--- .../agent-presets/minimal/agent.cordis.yml | 8 +++ .../agent-presets/standard/agent.cordis.yml | 20 +++--- apps/web/tests/shipped-composition.e2e.ts | 62 +++++++++++++++++++ packages/bundle/web-app/cordis.patch.yml | 13 +++- 6 files changed, 107 insertions(+), 36 deletions(-) diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 65d2716458..d068e00dea 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -70,17 +70,15 @@ # ── background tasks ──────────────────────────────────────────────────────── -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# above resolves it with `ctx.get`, and an entry-local realm here is invisible +# to every sibling row, so `run_in_background` would answer "background tasks +# unavailable" while these controls sat in the catalog. The registry is keyed by +# owning agent anyway, so one host instance serves every session. What a preset +# chooses is whether its agent can collect and stop background work at all. +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' # ── skills ────────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index f2cdeea159..91fa28a31d 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -64,17 +64,15 @@ # ── background tasks ──────────────────────────────────────────────────────── -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# above resolves it with `ctx.get`, and an entry-local realm here is invisible +# to every sibling row, so `run_in_background` would answer "background tasks +# unavailable" while these controls sat in the catalog. The registry is keyed by +# owning agent anyway, so one host instance serves every session. What a preset +# chooses is whether its agent can collect and stop background work at all. +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' # ── goals ─────────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 8ca6f0dcdf..cbccafe160 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -22,8 +22,16 @@ # never reached the model's shell at all. `tool-bash` consumes the host registry # from here; the executor behind it (`bash-sandbox`) is host-plane too, where the # sandbox policy owns it. +# +# `run_in_background` is off because this preset mounts no `tool-tasks`: the +# host task registry gates starts on SOME control surface being attached, and +# that set is process-wide, so another preset's controls would let this agent +# start work it has no `task_output` to collect. Disabling drops the parameter +# from the schema too, which is the honest surface for a two-tool benchmark. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' + config: + enableRunInBackground: false - id: tool-str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 66407faf1d..f73f4b3fba 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -63,17 +63,15 @@ # ── background tasks ──────────────────────────────────────────────────────── -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# above resolves it with `ctx.get`, and an entry-local realm here is invisible +# to every sibling row, so `run_in_background` would answer "background tasks +# unavailable" while these controls sat in the catalog. The registry is keyed by +# owning agent anyway, so one host instance serves every session. What a preset +# chooses is whether its agent can collect and stop background work at all. +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' # ── skills ────────────────────────────────────────────────────────────────── diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 5d929044b3..8241dfc0bc 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -5,6 +5,7 @@ // surface itself. import { tmpdir } from 'node:os' import { afterEach, expect, it } from 'vitest' +import { CallId } from '@deepseek-ai/dsh-llm' import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' import { SessionId } from '@deepseek-ai/dsh-session' // Empty type imports carry the tools/sandboxPolicy/approval Context merges. @@ -114,3 +115,64 @@ it('assembles the shipped Web catalog with the confined access default', async ( await commandHandle.dispose() } }, 120_000) + +it('lets a preset producer reach the background-task registry', async () => { + scaffold = await launchWebScaffold() + const ctx = scaffold.ctx + const handle = await ctx.agents.create({ + sessionId: SessionId('shipped-background-task'), + meta: { cwd: scaffold.workspaceCwd }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + const signal = new AbortController().signal + // `tool-bash` is a preset row and `tasks` is a host registry; the producer + // resolves it with `ctx.get`, so a registry hidden behind a preset realm + // fails here — with every task control still listed in the catalog above. + const started = await ctx.tools.execute({ + signal, + callId: CallId('shipped-bash-background'), + name: 'bash', + arguments: { + command: 'printf SHIPPED_BACKGROUND_OK', + description: 'shipped background probe', + run_in_background: true, + }, + agent: handle.agent, + }) + expect({ isError: started.isError, content: started.content }).toEqual({ + isError: false, + content: [{ type: 'text', text: 'started background task bash-1' }], + }) + + // The control surface reads what the producer started: same registry, one + // owner. A per-preset registry would list nothing here even on success. + const listed = await ctx.tools.execute({ + signal, + callId: CallId('shipped-task-list'), + name: 'task_list', + arguments: {}, + agent: handle.agent, + }) + expect(listed.isError).toBe(false) + expect(listed.content).toEqual([ + { type: 'text', text: expect.stringContaining('bash-1 [bash]') as unknown as string }, + ]) + + // The full round trip: the output a host-plane producer wrote is collected + // through a preset-plane control, which is the linkage the realm severed. + const collected = await ctx.tools.execute({ + signal, + callId: CallId('shipped-task-output'), + name: 'task_output', + arguments: { task_id: 'bash-1', wait: true }, + agent: handle.agent, + }) + expect(collected.isError).toBe(false) + expect(collected.content).toEqual([ + { type: 'text', text: expect.stringContaining('SHIPPED_BACKGROUND_OK') as unknown as string }, + ]) + } finally { + await handle.dispose() + } +}, 120_000) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index e4c4935a2a..ed08451f1b 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -218,10 +218,17 @@ - id: tool-bash disabled: true -- id: tool-tasks - disabled: true +# The background-task REGISTRY stays on the host plane; only the model-facing +# `task_*` controls move. Its producers — `tool-bash` here, `tool-pty` and a +# non-continuable `tool-subagent` elsewhere — are preset rows that resolve it +# with `ctx.get`, and an entry-local realm around the registry is invisible to +# every sibling row outside that realm, so `run_in_background` answered +# "background tasks unavailable" while the controls sat in the catalog. That is +# the `goals` criterion read from inside the preset: a Service a row outside its +# realm READS belongs to the plane both can see. The registry is keyed by owning +# agent, so one host instance serves every session exactly as before presets. -- id: tasks +- id: tool-tasks disabled: true - id: tool-fs From eaf9c09d96352657918dab7c9a4e95147bff0535 Mon Sep 17 00:00:00 2001 From: pku-xht <xht@deepseek.com> Date: Mon, 10 Aug 2026 14:22:20 +0800 Subject: [PATCH 074/229] test(subagent): normalize Windows Claude path casing --- .../subagent-claude-code/tests/real-product.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 7e1dae96d9..2a50d560a4 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -230,8 +230,11 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { expect(harness.spawnSpecs[0]?.argv.slice(0, 6)).toEqual([ 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', ]) - expect(harness.spawnSpecs[0]?.env?.DSH_CLAUDE_CODE_EXECUTABLE) - .toBe(`"${harness.executable}"`) + const batchExecutable = harness.spawnSpecs[0]?.env?.DSH_CLAUDE_CODE_EXECUTABLE + expect(batchExecutable?.startsWith('"')).toBe(true) + expect(batchExecutable?.endsWith('"')).toBe(true) + expect(batchExecutable?.slice(1, -1).toLowerCase()) + .toBe(harness.executable.toLowerCase()) } else { expect(harness.spawnSpecs[0]?.argv[0]).toBe(harness.executable) } From 75100ad2e5e0d081d3f81a22fdd6bd11bf464631 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 14:22:55 +0800 Subject: [PATCH 075/229] fix(agent-presets): repair Windows preset CI --- .../preset/agent-presets/README.i18n.yaml | 4 +-- packages/preset/agent-presets/README.md | 2 ++ packages/preset/agent-presets/README.zh.md | 2 ++ packages/preset/agent-presets/src/mount.ts | 13 +++++--- .../agent-presets/tests/authoring.spec.ts | 17 ++++++----- .../agent-presets/tests/discovery.spec.ts | 30 +++++++++++++++++-- .../preset/agent-presets/tests/mount.spec.ts | 19 +++++++++++- 7 files changed, 70 insertions(+), 17 deletions(-) diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index cb80d89ad8..65af853308 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: ed640cf053ac595dfb9c20c226f3c2ff34db93f6 -README.zh.md: 4e6fc0a4cf0db4b14b136cbad9f73eee64d9c170 +README.md: b6d469b26a0254adc654e5cc49d3df2d10817b2d +README.zh.md: 60c7bc695c27bf2c0169a0e405aa84aa711b9b21 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index ed640cf053..b6d469b26a 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -55,6 +55,8 @@ A row's **package name** resolves from the host composition, not from the preset A **relative** path still resolves from the preset's own directory, so a preset's own plugin files and skill directories travel with it. +An **absolute** filesystem path keeps its own location. The mount converts it to a `file:` URL before ESM import so POSIX paths and Windows drive-letter or UNC paths use a specifier Node accepts. + ### Display metadata A preset may publish display text in an optional `preset.yml` beside its composition: diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 4e6fc0a4cf..60c7bc695c 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -55,6 +55,8 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有 **相对**路径仍从 preset 自身的目录解析,因此 preset 自带的插件文件与 skill 目录会随它一同迁移。 +**绝对**文件系统路径则保留其自身位置。挂载会先将它转换为 `file:` URL 再交给 ESM 导入,从而使 POSIX 路径和 Windows 盘符或 UNC 路径都采用 Node 能够接受的说明符。 + ### 展示用元信息 preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本: diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index fac3319fa1..eb890255ca 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -14,6 +14,7 @@ * @module @deepseek-ai/dsh-agent-presets/mount */ +import { isAbsolute } from 'node:path' import { pathToFileURL } from 'node:url' import { Context, type Fiber } from 'cordis' import { Include } from '@cordisjs/plugin-include' @@ -69,21 +70,25 @@ class PresetTree extends Include { * where Node's upward `node_modules` walk never reaches the harness's own * dependencies, so every `@deepseek-ai/dsh-*` row would fail to import. The * mount records the host composition's base instead, which is inside the - * installed harness, and bare names resolve from there. + * installed harness, and bare names resolve from there. An absolute + * filesystem path names neither base and becomes a file URL before Node's + * ESM loader receives it, which is required for drive-letter paths on + * Windows. * @param name - the module specifier from the row. * @param getOuterStack - the loader's stack composer for import diagnostics. * @returns the imported module, or the `cordis:` builtin. */ override import(name: string, getOuterStack?: () => string[]): unknown { + const specifier = isAbsolute(name) ? pathToFileURL(name).href : name const base = harnessBase.get(this.config) /* v8 ignore next -- every PresetTree is constructed by `mountPreset`, which records the base first */ - if (base === undefined) return super.import(name, getOuterStack) + if (base === undefined) return super.import(specifier, getOuterStack) if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(name, getOuterStack) const internal = this.ctx.loader.internal /* v8 ignore next -- Node always supplies the internal module loader; the branch keeps a hypothetical embedder from losing the row's name in a resolution error. */ - if (internal === undefined) return super.import(name, getOuterStack) - return internal.import(name, base, {}) + if (internal === undefined) return super.import(specifier, getOuterStack) + return internal.import(specifier, base, {}) } /** diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index b71776c264..b6f4ef388a 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -65,20 +65,23 @@ describe('copying a preset', () => { expect(listed.find(preset => preset.id === 'mine')?.trust).toBe('user') }) - it('copies the whole directory, execute bits kept and group/other stripped', async () => { + it('copies the whole directory and tightens POSIX modes', async () => { await seedPreset(userRoot, 'source', { extras: { 'skills/demo/SKILL.md': '# demo\n', 'skills/demo/run.sh': '#!/bin/sh\n' }, }) - await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755) + if (process.platform !== 'win32') { + await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755) + } await ctx.agentPresets.copy('source', 'mine') expect(await readFile(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'), 'utf8')).toBe('# demo\n') - // A preset may ship runnable helpers; the copy keeps them runnable for the - // owner while withdrawing the world-readability of the install. - expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700) - expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600) - expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700) + // Windows mode bits are synthetic and cannot represent the inherited DACL. + if (process.platform !== 'win32') { + expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700) + expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600) + expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700) + } }) it('keeps the source description but never its name or order', async () => { diff --git a/packages/preset/agent-presets/tests/discovery.spec.ts b/packages/preset/agent-presets/tests/discovery.spec.ts index 55845c2251..165a4cd17f 100644 --- a/packages/preset/agent-presets/tests/discovery.spec.ts +++ b/packages/preset/agent-presets/tests/discovery.spec.ts @@ -1,14 +1,37 @@ -import { chmod, mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets' +const fsHarness = vi.hoisted(() => ({ + nextReadError: undefined as NodeJS.ErrnoException | undefined, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:fs/promises')>() + return { + ...actual, + readFile: (async (path: unknown, ...rest: never[]) => { + const error = fsHarness.nextReadError + if (error !== undefined) { + fsHarness.nextReadError = undefined + throw error + } + return (actual.readFile as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest) + }) as typeof actual.readFile, + } +}) + const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const } const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const } +beforeEach(() => { + fsHarness.nextReadError = undefined +}) + describe('display order', () => { it('puts declared order first, then everything else by id', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-order-')) @@ -177,10 +200,11 @@ describe('composition health', () => { await mkdir(join(root, 'sealed')) const path = join(root, 'sealed', COMPOSITION_FILE) await writeFile(path, '[]\n') - await chmod(path, 0o000) + fsHarness.nextReadError = Object.assign(new Error('EACCES: injected read failure'), { code: 'EACCES' }) const [preset] = await scanRoot({ path: root, trust: 'user' }) + expect(fsHarness.nextReadError).toBeUndefined() expect(preset?.broken).toMatch(/cannot be read/) }) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index b4988331cc..c84dae525b 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -11,7 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import AgentPresets, { COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent, } from '@deepseek-ai/dsh-agent-presets' @@ -84,6 +84,23 @@ beforeEach(async () => { }) describe('composing an agent from a preset', () => { + it('hands an absolute plugin path to Node as a file URL', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-absolute-plugin-')) + const presetDir = join(root, 'absolute') + const plugin = join(FIXTURES, 'plugins', 'contribute.js') + await mkdir(presetDir) + await writeFile( + join(presetDir, COMPOSITION_FILE), + `- id: only\n name: ${plugin}\n config:\n tool: absolute\n`, + ) + const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }] }) + const imported = vi.spyOn(scoped.loader.internal!, 'import') + + await agentOn(scoped, 'sess-absolute-plugin') + + expect(imported).toHaveBeenCalledWith(pathToFileURL(plugin).href, expect.any(String), {}) + }) + it('gives each session only its own preset\'s tools', async () => { const alpha = await agentOn(ctx, 'sess-alpha', 'standard') const beta = await agentOn(ctx, 'sess-beta', 'minimal') From 52fafa012e8616b5587863b0e2fe05221255a982 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 14:30:02 +0800 Subject: [PATCH 076/229] test(agent-presets): stabilize generation race coverage --- .../preset/agent-presets/tests/mount.spec.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index c84dae525b..77c549a689 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -542,6 +542,34 @@ describe('editing a composition file', () => { expect(livePresetMounts().filter(mount => mount.presetId === 'raced')).toHaveLength(2) }) + it('keeps a newer generation pointer when a stale refresh loses the swap race', async () => { + const { scoped, path } = await editable('guarded-refresh') + const preset = await scoped.agentPresets.resolve('guarded-refresh') + await agentOn(scoped, 'sess-guarded-refresh-seed', 'guarded-refresh') + const service = scoped.agentPresets as unknown as { + standing: Map<string, Promise<{ + key: unknown + scope: unknown + stamp: { mtimeMs: number; size: number } + }>> + ensureStanding(current: typeof preset): Promise<unknown> + } + const stalePromise = service.standing.get(preset.id)! + const stale = await stalePromise + await writeFile(path, rowFor('afterwards')) + const { mtimeMs, size } = await stat(path) + const newer = { ...stale, stamp: { mtimeMs, size } } + const newerPromise = Promise.resolve(newer) + + // `await pending` yields before the guarded delete, letting the winning + // refresher replace the pointer deterministically instead of by timing. + const refresh = service.ensureStanding(preset) + service.standing.set(preset.id, newerPromise) + + expect(await refresh).toBe(newer) + expect(service.standing.get(preset.id)).toBe(newerPromise) + }) + it('hands a host reader the standing key without starting an agent', async () => { const { scoped } = await editable('cold-read') From 259d998455d625679549f8941a1ddba9a6ec5516 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 14:35:13 +0800 Subject: [PATCH 077/229] fix(web): follow a blank session's preset switch in the slash catalog Presets own the rows that decide what a session's `/` menu contains, but both browser catalogs cache per session and had no invalidation edge for a recompose: `commands/changed` is registry-wide and recomposing registers nothing, so the menu kept serving the composition the session no longer ran. The host stream now frames the logged `agent-preset/selected` commit as `host/session-preset-changed`; the runtime bridges it to the typed `session/preset-changed` event, `ui-command` soft-refreshes that session's directory key and `ui-skill` invalidates its catalog entry. Reaching the host on a second switch was a separate defect: the list-row identity guard compared every summary field except `agentPreset`, and the merge keeps the row's `updatedAt`, so a switched row looked unchanged and served its cached instance forever. The hero chip compares the pick against that row, so switching back to the creation-time preset sent no RPC at all. --- ...n-row-identity-covers-the-preset.i18n.yaml | 6 + ...-session-row-identity-covers-the-preset.md | 37 ++++++ ...ssion-row-identity-covers-the-preset.zh.md | 37 ++++++ ...sh-catalog-follows-preset-switch.i18n.yaml | 6 + ...-10-slash-catalog-follows-preset-switch.md | 41 +++++++ ...-slash-catalog-follows-preset-switch.zh.md | 41 +++++++ apps/web/tests/agent-preset-selection.e2e.ts | 105 +++++++++++++++--- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 1 + docs/event-producer-consumer.zh.md | 1 + 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 | 15 +++ .../runtime/src/client/sessions/manager.ts | 2 +- .../runtime/tests/sessions-service.spec.ts | 17 +++ .../client/runtime/tests/wire-events.spec.ts | 14 ++- packages/client/ui-command/README.i18n.yaml | 4 +- packages/client/ui-command/README.md | 2 +- packages/client/ui-command/README.zh.md | 2 +- .../client/ui-command/src/client/service.ts | 5 + .../client/ui-command/tests/service.spec.ts | 24 ++++ packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-skill/src/client/index.ts | 7 +- .../ui-skill/tests/browser-plugin.spec.ts | 15 +++ packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 11 ++ .../host/apiproxy/src/api/events.schema.ts | 1 + packages/host/apiproxy/src/api/events.ts | 11 ++ .../tests/api-proxy-agent-preset.spec.ts | 32 ++++++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 1 + scripts/gen-cordis-catalog.ts | 1 + 36 files changed, 433 insertions(+), 34 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.zh.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.i18n.yaml new file mode 100644 index 0000000000..aaa06cf4fe --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.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-session-row-identity-covers-the-preset.md +2026-08-10-session-row-identity-covers-the-preset.md: 7a89dcb4e4ae292a06a1743842d2e9cf6bd96282 +2026-08-10-session-row-identity-covers-the-preset.zh.md: 7ffa3423818bcc867c942651540db1975737e073 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md new file mode 100644 index 0000000000..7a89dcb4e4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md @@ -0,0 +1,37 @@ +# Agent Note: The session-row identity guard covers the preset + +Status: implemented + +English | [中文](2026-08-10-session-row-identity-covers-the-preset.zh.md) + +## Problem + +`SessionManager.buildListSnapshot` memoizes list rows by value: a wire refresh mints all-new summary objects, so an entry equal to the cached one is replaced by the cached instance, and every `SessionListItem` memo downstream keeps hitting. The stated contract is "reuse the cached object when every field matches"; the comparison enumerated the fields by hand and did not enumerate `agentPreset`. + +A confirmed preset switch moves exactly that one field. `noteAgentPreset` upserts it and `applyMutation` merges it in — the merge deliberately does not take the mutation's `updatedAt`, so a switched row differs from its cached twin in the preset and in nothing else. The guard therefore judged the row unchanged and served the stale instance, permanently: the manager's own summaries said `minimal` while every reader of the projected snapshot went on reading `standard`. + +The hero chip is one of those readers, and it compares the pick against that row before sending anything. Switching back to the preset the session was created under looked to it like "already on that preset", so it dropped the stage and sent no RPC at all — the chip label moved while the composition did not. A session could be switched away from its creation-time preset once and never back. + +## Decision + +The identity guard compares `agentPreset` alongside the other summary fields, which is what "every field matches" already claimed. Nothing else changes: the memoization, the merge, and the chip's no-op check all stay as they are, because each is correct once the row it reads is. + +## Alternatives considered + +**Have the chip re-read the host instead of the list row.** It would route around the stale row, but the row is also what the session header labels itself from, so the staleness would survive in the surface where it is most visible — and any future reader of `SessionSummary.agentPreset` would inherit the same trap. + +**Drop the entry-identity memoization and rebuild rows every snapshot.** It removes the whole class of missing-field bugs, at the cost the memo exists to avoid: a wire refresh mints new objects for every row, so each refresh would re-render the entire session list. + +**Compare summaries structurally rather than field by field.** A generic deep comparison cannot be added blind: the row carries `projectionValues`, whose reference identity is the deliberate signal that the projection store republished, and folding it into a value comparison would either re-render on every projection tick or mask a real one. + +## Consequences + +Every field a session row carries now participates in row identity, so a surface reading `SessionSummary.agentPreset` sees a switch as soon as the host confirms it — the header label included. The guard is still a hand-written enumeration, so a field added to `SessionSummary` later must be added here too; the `sessions-service` projection test names the failure mode for the next such field rather than only pinning this one. + +## Testing + +`sessions-service.spec.ts` feeds a blank row, notes a switch, and asserts the projected snapshot reports the new preset — it fails on the old guard because the row differs in nothing else. The `agent-preset-selection` web e2e switches down and back up, asserting the host honors the second switch and the `/` catalog returns with it; without this fix the second switch never reaches the host at all. + +## Related + +The same e2e covers [the catalog-invalidation fix](2026-08-10-slash-catalog-follows-preset-switch.md), which is what makes the menu follow either switch once the switch itself lands. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.zh.md new file mode 100644 index 0000000000..7ffa342381 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.zh.md @@ -0,0 +1,37 @@ +# Agent Note:会话行的标识判定纳入 preset + +Status: implemented + +[English](2026-08-10-session-row-identity-covers-the-preset.md) | 中文 + +## Problem + +`SessionManager.buildListSnapshot` 按值对列表行做记忆化:一次 wire 刷新会铸造全新的 summary 对象,因此与缓存项相等的行会被替换为缓存实例,下游每一个 `SessionListItem` memo 才能持续命中。它声明的约定是「每个字段都相同就复用缓存对象」,而那段比较是手写枚举字段的,其中没有 `agentPreset`。 + +一次已确认的 preset 切换恰好只移动这一个字段。`noteAgentPreset` 把它 upsert 进去,`applyMutation` 合并它——该合并有意不采用 mutation 的 `updatedAt`,因此切换后的行与它的缓存孪生只在 preset 上不同,别处一致。于是标识判定认为这一行没变,永久地提供了过期实例:manager 自己的 summaries 是 `minimal`,而所有读取投影快照的一方继续读到 `standard`。 + +hero 上的 chip 正是其中一个读取方,而且它在发出任何请求之前会拿这次选择和那一行比较。切回会话创建时的那个 preset,在它看来就是「已经是这个 preset 了」,于是丢弃 stage、根本不发 RPC——chip 的标签变了,组成没变。一个会话可以从创建时的 preset 切走一次,然后再也切不回来。 + +## Decision + +标识判定把 `agentPreset` 与其余 summary 字段一起比较,这本就是「每个字段都相同」所声称的内容。其他一概不动:记忆化、合并、chip 的 no-op 检查各自都是对的——只要它们读到的那一行是对的。 + +## Alternatives considered + +**让 chip 改为直接读宿主,而不是读列表行。** 这样能绕开过期的行,但会话头部的标签同样以这一行为准,过期状态会在最显眼的界面里留下来;而且将来任何 `SessionSummary.agentPreset` 的读取方都会继承同一个陷阱。 + +**去掉行标识记忆化,每次快照都重建行。** 这能整类消除「漏字段」缺陷,代价却正是这个 memo 存在的理由:一次 wire 刷新会为每一行铸造新对象,于是每次刷新都要重渲染整个会话列表。 + +**改成结构化比较,而不是逐字段枚举。** 通用的深比较不能盲目加:行上带有 `projectionValues`,它的引用标识本身就是「投影 store 重新发布了」这一有意为之的信号,把它折进值比较,要么每个投影 tick 都重渲染,要么把一次真实变化掩盖掉。 + +## Consequences + +会话行携带的每个字段现在都参与行标识,因此读取 `SessionSummary.agentPreset` 的界面会在宿主确认后立刻看到切换,会话头部标签也包含在内。该判定仍是手写枚举,所以将来给 `SessionSummary` 新增字段时必须同步加进来;`sessions-service` 的投影测试为下一个这样的字段点明了失效形态,而不只是钉住这一次。 + +## Testing + +`sessions-service.spec.ts` 喂入一行空会话、记录一次切换,并断言投影快照报告的是新 preset——在旧判定下它会失败,因为这一行别处都没变。`agent-preset-selection` web e2e 先向下切再向上切,断言宿主认可第二次切换、`/` 目录随之回来;没有这次修复,第二次切换根本到不了宿主。 + +## Related + +同一条 e2e 也覆盖[目录失效的修复](2026-08-10-slash-catalog-follows-preset-switch.md)——正是它让菜单在切换真正落地之后跟随任一方向的切换。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml new file mode 100644 index 0000000000..38cd8786b5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.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-slash-catalog-follows-preset-switch.md +2026-08-10-slash-catalog-follows-preset-switch.md: 4f32347e04e9b1cde024a59a32fcfd3cca64172a +2026-08-10-slash-catalog-follows-preset-switch.zh.md: fb30df74a93a9eb913dc43b43c3065c6255bcab9 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md new file mode 100644 index 0000000000..4f32347e04 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md @@ -0,0 +1,41 @@ +# Agent Note: The slash catalog follows a blank session's preset switch + +Status: implemented + +English | [中文](2026-08-10-slash-catalog-follows-preset-switch.zh.md) + +## Problem + +Presets moved the rows that decide what a session's `/` menu contains. The Web composition disables host-plane `skill-local`, `tool-skill`, `plan-mode`, and `command-compact`; a preset supplies them, so which commands and skills exist is a property of the session's composition rather than of the deployment. + +Both browser catalogs cache per session — `CommandDirectory` in `dsh-client-ui-command`, the single-flight fetch map in `dsh-client-ui-skill` — and the composer warms both at scope birth, under whatever preset the session was created with. The hero chip then lets the user recompose the still-blank session, and neither cache had an invalidation edge for that: `commands/changed` is registry-wide and `connection/reset` needs a reconnect. `agentPresets.recompose` re-parents the agent's scope onto a standing mount that may already exist, so it registers nothing and the registry-wide signal never fires for it. + +The menu therefore kept serving the composition the session no longer ran. Switching down left `compact`, `plan`, and every project skill listed; switching up left the narrower catalog — the four host-plane rows and the client's own `model` contribution — with no skills at all, which is what the bug report described. The catalog only healed when an unrelated registry change or a reconnect happened to invalidate it. + +## Decision + +The switch's commit point is the logged `agent-preset/selected` event. The host stream frames it as `host/session-preset-changed { sessionId, agentPreset }`, the browser runtime bridges that frame to the typed `session/preset-changed` ctx event beside the registry-invalidation bridges it already owns, and each catalog owner drops its own entry for that session: `ui-command` soft-refreshes the key (the old snapshot keeps serving the open menu until the new one lands), `ui-skill` invalidates it (aborting an in-flight prewarm, so a warm racing the switch cannot publish the stale catalog). + +The frame is per session and carries no catalog. Deriving it from the logged event rather than from the RPC handler's return keeps one authority for "this session's composition changed": every connected client observes the switch, not only the tab that issued it, and a client that is not the switcher never has to infer it from a registry signal that will not come. + +## Alternatives considered + +**Invalidate in the client's own `agentPresets.select` callback.** Smallest change, and the preset is locked after the first turn, so the hero chip is the only place a switch can originate. Rejected because the invalidation would then live in the surface that happens to issue the RPC rather than at the commit point: a second tab on the same blank session keeps a stale menu, and any future host-side recomposition has no signal at all. + +**Derive the client event from the existing `session/event` mux frame.** The logged event already reaches every subscribed client, so no new wire type would be needed. Rejected on face separation: narrowing `event.type` to `agent-preset/selected` requires the `SessionEventMap` augmentation, and the only ways to load it in the Client program are a project reference to `dsh-agent-presets` — which drags the host `ctx.sessions` merge into a program that publishes its own — or a cast that defeats the discriminant. + +**Reuse `host/commands-changed`.** It is the existing catalog-invalidation frame, but it is registry-wide, carries no session, and says nothing about skills; a client would repull every session's commands and still never refresh a skill catalog. + +## Consequences + +The wire gains one frame and the Client one typed event, and every catalog a preset decides now has one place to subscribe: a future per-session surface derived from the composition invalidates on the same signal instead of inventing another. The cost is that the frame is a second reader of a logged fact — the host stream must keep deriving it from `agent-preset/selected`, so a future switch path that recomposes without logging would go unannounced. `ui-command` stays soft (the open menu never blanks) while `ui-skill` drops its entry outright, because a skill catalog has no partial-serve mode; a menu opened inside the refetch window shows no skills for that instant rather than the wrong ones. + +## Testing + +`api-proxy-agent-preset.spec.ts` asserts the committed switch frames once with the session and its new preset; `wire-events.spec.ts` asserts the frame-to-event bridge; the `ui-command` and `ui-skill` specs assert that the event repulls the recomposed session and leaves every other session's cache serving. The `agent-preset-selection` web e2e seeds a project skill and, after the hero chip applies `minimal`, asserts the `/` menu drops `compact`, `plan`, and the skill while keeping the host-plane rows — the assembled-application evidence that the panel follows the composition. + +That e2e also stopped reading its staged-pick assertion off the serialized session list: the seeded session records `minimal` too, so the substring answered before the switch had landed. It now addresses the live session by id. + +## Related + +Reaching the host on a SECOND switch is a separate defect with its own cause and fix: [the session-row identity guard](2026-08-10-session-row-identity-covers-the-preset.md). Until it landed, the e2e below could only exercise the first switch — the invalidation edge here is direction-blind, but the switch it reacts to has to happen. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md new file mode 100644 index 0000000000..fb30df74a9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md @@ -0,0 +1,41 @@ +# Agent Note:斜杠目录跟随空会话的 preset 切换 + +Status: implemented + +[English](2026-08-10-slash-catalog-follows-preset-switch.md) | 中文 + +## Problem + +preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿主面的 `skill-local`、`tool-skill`、`plan-mode` 和 `command-compact`,改由 preset 提供,因此一个会话有哪些命令和技能,是它自身组成的属性,而不是部署的属性。 + +浏览器侧两份目录都按会话缓存——`dsh-client-ui-command` 的 `CommandDirectory`,`dsh-client-ui-skill` 的 single-flight 拉取表——并且 composer 在 scope 出生时就按会话创建时的 preset 预热了它们。随后 hero 上的 chip 允许用户重组这个仍为空的会话,而两份缓存都没有对应的失效边:`commands/changed` 是注册表级的,`connection/reset` 需要重连。`agentPresets.recompose` 只是把 agent 的 scope 重新挂接到一个可能已经存在的常驻挂载上,不产生任何注册,注册表级信号因此永远不会为它触发。 + +于是菜单继续提供会话已经不再运行的那套组成。向下切换后 `compact`、`plan` 和全部项目技能仍列在菜单里;向上切换后留在原地的是更窄的目录——四条宿主面行加客户端自己的 `model` 贡献——而且完全没有技能,这正是 bug 报告描述的现象。只有当某个无关的注册表变化或一次重连恰好使其失效时,目录才会自愈。 + +## Decision + +这次切换的提交点是落账的 `agent-preset/selected` 事件。宿主流把它成帧为 `host/session-preset-changed { sessionId, agentPreset }`,浏览器运行时在它已经拥有的那组注册表失效桥接旁,把该帧桥接为类型化的 `session/preset-changed` ctx 事件,两份目录各自丢弃该会话的那一项:`ui-command` 软刷新该键(新快照落地前,旧快照继续服务已打开的菜单),`ui-skill` 让它失效(并中止在途的预热,使一次与切换赛跑的 warm 无法发布过期目录)。 + +该帧按会话粒度,且不携带目录。从落账事件而不是 RPC 处理器的返回值派生它,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。 + +## Alternatives considered + +**在客户端自己的 `agentPresets.select` 回调里就地失效。** 改动最小,而且第一轮之后 preset 就锁定,hero 上的 chip 是切换唯一可能的发起处。否决理由是失效逻辑会落在恰好发起 RPC 的那个界面上,而不是提交点:同一个空会话在第二个标签页里仍是过期菜单,将来任何宿主侧的重组也完全没有信号。 + +**从既有的 `session/event` mux 帧派生客户端事件。** 落账事件本来就会送达每个已订阅的客户端,不需要新增协议类型。因面(face)分离而否决:把 `event.type` 收窄到 `agent-preset/selected` 需要 `SessionEventMap` 增补,而在 Client 程序里加载它只有两条路——引用 `dsh-agent-presets` 工程,那会把宿主的 `ctx.sessions` 合并拖进一个自己也发布同名服务的程序;或者用一次类型断言绕过判别式。 + +**复用 `host/commands-changed`。** 它是既有的目录失效帧,但它是注册表级的、不带会话、也与技能无关;客户端会把每个会话的命令都重拉一遍,却依然永远刷不新技能目录。 + +## Consequences + +协议多了一个帧,Client 多了一个类型化事件,而每一份由 preset 决定的目录从此有了统一的订阅点:将来任何从组成派生的按会话界面,都在同一个信号上失效,而不必再发明一个。代价是该帧成为一项落账事实的第二个读者——宿主流必须持续从 `agent-preset/selected` 派生它,因此将来若出现一条不落账就重组的切换路径,它将无人宣告。`ui-command` 保持软失效(已打开的菜单不会变空),而 `ui-skill` 直接丢弃该项,因为技能目录没有「部分可服务」的状态;在重拉窗口内打开的菜单,那一瞬间显示的是没有技能,而不是错误的技能。 + +## Testing + +`api-proxy-agent-preset.spec.ts` 断言已提交的切换恰好成帧一次,并带上会话与新 preset;`wire-events.spec.ts` 断言帧到事件的桥接;`ui-command` 与 `ui-skill` 的 spec 断言该事件只重拉被重组的会话,其他会话的缓存继续服务。`agent-preset-selection` web e2e 播种一个项目技能,并在 hero chip 应用 `minimal` 之后断言 `/` 菜单丢掉了 `compact`、`plan` 和该技能,同时保留宿主面的那几行——这是面板跟随组成的整装应用证据。 + +同一条 e2e 也不再从序列化后的会话列表里读它的 staged-pick 断言:被播种的会话同样记录着 `minimal`,子串匹配在切换落地之前就会通过。现在它按 id 寻址那个活跃会话。 + +## Related + +第二次切换能否到达宿主是另一个缺陷,有各自的成因与修复:[会话行的标识判定](2026-08-10-session-row-identity-covers-the-preset.md)。在它落地之前,下面那条 e2e 只能演练第一次切换——这里的失效边对方向无感,但它所响应的那次切换必须真的发生。 diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 69672f49e1..71e1a28b05 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -11,6 +11,7 @@ // // Zero model calls: no replay fixture mounts, so a stray stream fails loud. import { fileURLToPath } from 'node:url' +import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' @@ -29,6 +30,30 @@ const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md') const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'agent-preset-selection-web-e2e' +/** A project skill only a preset that mounts `skill-local` can discover. */ +const SKILL_NAME = 'preset-catalog-demo' + +/** + * Seed one project skill under the connected workspace. + * + * Local skill discovery is a PRESET row, so this file is visible through + * `standard` and invisible through `minimal` — which makes the '/' menu's + * skill group a statement about the session's composition. + * @param workspaceCwd - the scaffold's temp project parent. + */ +async function seedWorkspaceSkill(workspaceCwd: string): Promise<void> { + const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), [ + '---', + `name: ${SKILL_NAME}`, + 'description: Prove the slash catalog follows the session composition', + '---', + '', + 'Body.', + '', + ].join('\n')) +} /** * A settled one-turn session with no model content: this lane asserts chrome @@ -53,6 +78,35 @@ function seedLog(): string { ].join('\n') } +/** + * The preset the host reports for the blank session the workspace connect + * produced. Addressed by id rather than by scanning the serialized list: the + * seeded session records `minimal` too, so a substring match over the whole + * list answers before the switch has landed. + * @param baseUrl - the scaffold's origin. + * @returns the live session's preset, or undefined before it is listed. + */ +async function livePreset(baseUrl: string): Promise<string | undefined> { + const response = await fetch(`${baseUrl}/api/session.list`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: 'agent-preset-live', method: 'session.list', payload: {}, + }), + }) + const body = await response.json() as { + result: { value?: { items: { sessionId: string; agentPreset?: string }[] } } + } + return body.result.value?.items.find(item => item.sessionId !== SEED_ID)?.agentPreset +} + +/** Every option label the trigger menu currently lists. */ +async function menuOptions(page: Page): Promise<string[]> { + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + await menu.waitFor({ timeout: 10_000 }) + return await menu.getByRole('option').allTextContents() +} + describe('web e2e: agent-preset selection', () => { let scaffold: WebScaffold let browser: Browser @@ -67,6 +121,7 @@ describe('web e2e: agent-preset selection', () => { // records `minimal` is what makes the header label a claim about the // session rather than an echo of the current default. await seedSession(scaffold, seedLog(), SEED_ID, 'minimal') + await seedWorkspaceSkill(scaffold.workspaceCwd) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) @@ -114,21 +169,45 @@ describe('web e2e: agent-preset selection', () => { // The chip stages; the blank session the workspace connect produced is // what the stage lands on. The host's own answer is what comes back. - await expect.poll(async () => { - const response = await fetch(`${scaffold.baseUrl}/api/session.list`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - type: 'client-request', rpcId: 'agent-preset-stage', method: 'session.list', payload: {}, - }), - }) - const body = await response.json() as { - result: { value?: { sessions: { blank: boolean; agentPreset?: string }[] } } - } - return JSON.stringify(body.result.value?.sessions ?? body.result) - }, { timeout: 15_000 }).toContain('minimal') + await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('minimal') }) + it('re-reads the slash catalog through the composition the switch installed', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog')) + const composer = page.locator('textarea:enabled').last() + + // `minimal` (applied above) mounts neither the compaction group nor plan + // mode nor local skill discovery, so the catalog the composer warmed + // under the deployment default must not survive the switch. + await composer.fill('/') + await expect.poll(() => menuOptions(page), { timeout: 15_000 }) + .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) + const onMinimal = await menuOptions(page) + expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false) + expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false) + // The host-plane commands and the client's own contribution are the + // floor: they belong to no preset and never move. + expect(onMinimal.some(option => option.startsWith('goal'))).toBe(true) + expect(onMinimal.some(option => option.startsWith('model'))).toBe(true) + await composer.fill('') + + // Switching back up reaches the host at all — the chip compares the pick + // against its list row, so a row that never reprojected the first switch + // answers "already standard" and sends nothing — and restores the catalog + // instead of leaving the session reading the narrower composition. + await page.getByRole('button', { name: '极简模式' }).click() + await page.getByRole('menuitem', { name: /^标准模式/ }).first().click() + await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('standard') + + await composer.fill('/') + await expect.poll(() => menuOptions(page), { timeout: 15_000 }) + .toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) + const onStandard = await menuOptions(page) + expect(onStandard.some(option => option.startsWith('compact'))).toBe(true) + expect(onStandard.some(option => option.startsWith('plan'))).toBe(true) + await composer.fill('') + }, 90_000) + it('labels a resumed session with the preset it was created under', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header')) // The seeded session's cwd is the scaffold root rather than the connected diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 20fa4fa5e5..9b71f640ed 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: 3b8a6b1dd155fd1350b164f1dd2d2bf0ec26a4a5 -event-producer-consumer.zh.md: 12de167fcd1217f00a8ae719ef3191a4873a2799 +event-producer-consumer.md: 70de749c328f1d901ff6f9bc0d97cd52a6f3bf63 +event-producer-consumer.zh.md: 6c49c33a1b1197a7da9bccfc161b7cfa6b6a548f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3b8a6b1dd1..70de749c32 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -70,6 +70,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | +| `session/preset-changed` | `runtime` (`emit`) | `ui-command` | | `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 12de167fcd..6c49c33a1b 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -72,6 +72,7 @@ | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | +| `session/preset-changed` | `runtime` (`emit`) | `ui-command` | | `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index a5d2c2a44f..29d5a2d4dc 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: 0a7d9975093da558af623ee9940f4be398526821 -README.zh.md: 41388e4ba564baa61cfdaaacb74f4f5ea053d41a +README.md: b1c0c8e5b6aa93f5e79b4b75c5f8db89bd656688 +README.zh.md: b6add06324bf9fc5cf4a93d89d88072609a67c50 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 0a7d997509..b1c0c8e5b6 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ 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`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. 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 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. 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. ## Slot declaration injection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 41388e4ba5..b6add06324 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [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`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 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 对象以及 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.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` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 ## Slot 声明注入 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 766656a7f9..955431d509 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -181,6 +181,18 @@ declare module 'cordis' { * @mode emit */ 'models/changed'(): void + /** + * One session's agent preset changed (host/session-preset-changed + * passthrough), so everything its composition decides — the command + * catalog, the skill catalog — is stale for that session and no other. + * Every connected client observes it, not only the one that issued the + * switch. Subscribers refetch their own session-keyed caches; the frame + * carries no catalog. + * @mode emit + * @param sessionId - the session whose composition changed. + * @param agentPreset - the preset it now runs. + */ + 'session/preset-changed'(sessionId: SessionId, agentPreset: string): void /** * A connection generation was (re-)established. Wire-derived caches must * treat their state as stale and repull (commands directory; the queue @@ -244,6 +256,9 @@ export function apply(ctx: Context): void { // and model surfaces) subscribe on ctx. const frame = envelope.payload if (frame.type === 'host/commands-changed') ctx.emit('commands/changed') + else if (frame.type === 'host/session-preset-changed') { + ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset) + } 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') diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index ab25781353..ce61351cca 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -1005,7 +1005,7 @@ export class SessionManager { const prev = this.entryCache.get(entry.sessionId) if ( prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running - && prev.blank === entry.blank + && prev.blank === entry.blank && prev.agentPreset === entry.agentPreset && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth && prev.pendingInteraction === entry.pendingInteraction diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 0a588e8329..e7d702f40a 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -35,6 +35,7 @@ type FeedRow = { origin?: 'subagent' running?: boolean blank?: boolean + agentPreset?: string } async function feedList(b: Bench, rows: FeedRow[]): Promise<void> { @@ -44,6 +45,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> { ...(r.cwd !== undefined ? { cwd: r.cwd } : {}), ...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}), ...(r.origin !== undefined ? { origin: r.origin } : {}), + ...(r.agentPreset !== undefined ? { agentPreset: r.agentPreset } : {}), })), }) as never) await b.svc.refresh() @@ -70,6 +72,21 @@ describe('list store projection', () => { expect(state.byId[sid('s2')]?.title).toBeUndefined() }) + it('reprojects a blank session whose composition switched and nothing else moved', async () => { + const b = bench() + await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }]) + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('standard') + + // A confirmed switch moves the preset alone: the row keeps its updatedAt, + // title, running, and blank bits, so an identity guard blind to the preset + // would serve the old row forever — and every reader (the hero chip's own + // no-op check, the header label) would keep the composition it replaced. + b.svc.noteAgentPreset(sid('s1'), 'minimal') + await Promise.resolve() + + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal') + }) + it('reflects live increments (host stream via manager) into the store', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 5b71588732..e82c4cae3b 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -1,6 +1,7 @@ /** * Wire-to-typed-event bridge: host/commands-changed - * → ctx 'commands/changed'; each established connection generation → + * → ctx 'commands/changed'; host/session-preset-changed → + * ctx 'session/preset-changed'; each established connection generation → * ctx 'connection/reset' (the forced cache-invalidation broadcast). */ import { Context } from 'cordis' @@ -67,6 +68,17 @@ describe('wire event bridge', () => { ]) }) + it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => { + const bench = await mount() + const seen: Array<[string, string]> = [] + bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) }) + bench.sinks?.onHostEnvelope?.({ + rpcId: 'r1' as never, + payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' }, + }) + expect(seen).toEqual([['s1', 'minimal']]) + }) + it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => { const bench = await mount() let resets = 0 diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index e59b4b2a12..acf611bbca 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/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-command/README.md -README.md: bc7386c8fca3b5c623473328bee6322fa7295277 -README.zh.md: 54190ac9144b1bfc12ba84a47474311d5a5391ea +README.md: db785e769cb40235a77d05b4b66d096896a35d8a +README.zh.md: f0f23319a8919a0dee715e9da03ab064b6e3298a diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index bc7386c8fc..db785e769c 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -6,7 +6,7 @@ Client command surface (`ctx.command`): the session-keyed command-directory cach `src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute. -`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. +`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies) and by `session/preset-changed` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md). diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index 54190ac914..f0f23319a8 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -6,7 +6,7 @@ `src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claim(space / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。 -`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 +`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),也由 `session/preset-changed` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。 diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 33b42f6b51..f17f3950d2 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -124,6 +124,11 @@ export class CommandService extends Service implements CommandServiceContract { warm: (session) => { this.directory.warm(session.sessionId) }, }), 'command: slash source') ctx.on('commands/changed', () => { this.directory.invalidateAll() }) + // A preset switch changes which commands ONE session's agent resolves and + // registers nothing globally, so the registry-wide signal above never + // fires for it: repull that key alone, soft, so the old snapshot serves + // the menu until the new one lands. + ctx.on('session/preset-changed', (sessionId) => { void this.directory.refresh(sessionId) }) ctx.on('connection/reset', () => { this.directory.resetConnected() }) } diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index bd6d72c916..f7ee172b8e 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -617,6 +617,30 @@ describe('directory invalidation events', () => { expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() }) + it('session/preset-changed repulls the recomposed session and leaves the others served', async () => { + const rounds = new Map<SessionId, number>() + const { ctx, source, warm } = await bench({ + commands: (payload) => { + const round = (rounds.get(payload.sessionId) ?? 0) + 1 + rounds.set(payload.sessionId, round) + return Promise.resolve({ + commands: round === 1 + ? S1_CMDS + : [{ name: 'fresh', description: '', input: { hint: 'h' } }], + }) + }, + }) + await warm(proj('s1')) + await warm(proj('s2')) + // A preset switch changes which commands one session's agent resolves; + // every other session keeps the catalog its own composition serves. + ctx.emit('session/preset-changed', sid('s1'), 'minimal') + await new Promise(resolve => setTimeout(resolve, 0)) + expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined() + expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() + expect(source.matchSpace!(proj('s2'), '/goal')).not.toBeUndefined() + }) + it('connection/reset hard-drops every session key until its rewarm lands', async () => { let block = false let release!: (value: { commands: CommandDescriptor[] }) => void diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index c9d9e0b69c..475c844c48 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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-skill/README.md -README.md: 677ac215d299fca695a6b27c564779ef1d3fd6ee -README.zh.md: 8f1f69b26a932aaa300bdda1d4ec7b2fa749fe3c +README.md: f6bf71bab5c5335d3da073101bcbafb30d1c2757 +README.zh.md: eae61780df7ccee350956dc542ebda70c671feb3 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 677ac215d2..f6bf71bab5 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. +Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry, `session/preset-changed` drops that one session's entry (the catalog belongs to the preset, and a blank session may switch after the warm), and `connection/reset` clears everything. Results filter by `startsWith(query)`. A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every front end, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 8f1f69b26a..eae61780df 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 +skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`session/preset-changed` 丢弃该会话这一项(目录属于 preset,而空会话可能在预热之后才切换),`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每一种前端注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 524cd180cc..8e805f469a 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -17,7 +17,9 @@ * Catalog fetches are cached per session (the small twin of the ui-command * directory): the per-keystroke candidates re-poll filters a settled * snapshot locally, so one session costs one RPC. The scope-birth warm hook - * prewarms the session's key; connection/reset clears everything — the host + * prewarms the session's key; a preset switch drops that one key (the + * catalog is the preset's, and a blank session may switch after the warm); + * connection/reset clears everything — the host * catalog may differ across generations. A shared in-flight fetch * deliberately outlives any single menu interaction: closing the menu must * not kill the prewarm other consumers will hit, so it carries its own @@ -174,6 +176,9 @@ export function apply(ctx: ClientContext): void { }, } const slash = ctx.get('slash') as SlashServiceContract + // A preset decides which skill providers an agent reads, so a switched + // session's cached catalog belongs to the composition it no longer runs. + ctx.on('session/preset-changed', invalidate) ctx.on('connection/reset', clearAll) ctx.effect(() => { const unregister = slash.registerSource(source) diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index f33924e977..5e143c0b96 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -263,6 +263,21 @@ describe('catalog cache', () => { expect(payloads).toHaveLength(2) }) + it('session/preset-changed clears only the recomposed session', async () => { + const { list, payloads } = countingList() + const { ctx, source } = await bench(list) + await source.candidates(proj('s1'), req('')) + await source.candidates(proj('s2'), req('')) + expect(payloads).toHaveLength(2) + // The catalog a preset supplies is the preset's; the other session's + // composition did not change, so its cached catalog still holds. + ctx.emit('session/preset-changed', sid('s1'), 'minimal') + await source.candidates(proj('s1'), req('')) + await source.candidates(proj('s2'), req('')) + expect(payloads).toHaveLength(3) + expect(payloads[2]).toEqual({ sessionId: 's1' }) + }) + it('connection/reset clears every cached session', async () => { const { list, payloads } = countingList() const { ctx, source } = await bench(list) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index f449e05a28..db0e030966 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: 98fcdda155286feb23aaf294cab76529ce31cfc1 -README.zh.md: 8cfa7e527a327d9c342a5cf6cf28163f5b45df1c +README.md: 54ac412ca384690a6370c7ee50c54a89972e41b5 +README.zh.md: 94d3caa88b3813a1cc764f69a427ae31257e4e48 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 98fcdda155..54ac412ca3 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -50,7 +50,7 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse `agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the registry-wide catalog invalidation frame: clients refetch `command.list` instead of diffing. `host/session-preset-changed` is its per-session counterpart, framed off the logged `agent-preset/selected` commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 8cfa7e527a..94d3caa88b 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -50,7 +50,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此每一种前端(web、TUI、ACP(Agent Client Protocol)、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此每一种前端(web、TUI、ACP(Agent Client Protocol)、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是注册表级目录失效帧:客户端重新拉取 `command.list` 而不是做差分。`host/session-preset-changed` 是它按会话粒度的对应物,由落账的 `agent-preset/selected` 提交点成帧:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 1fbcfadd2d..70ad99ee89 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -3164,6 +3164,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ctx.on('commands/change', () => { queue.push(frame({ type: 'host/commands-changed' })) }), + // The recompose itself registers nothing (it re-parents the agent's + // scope onto a standing mount that may already exist), so the + // logged selection is the only commit point a client can follow. + ctx.on('session/event', (session: Session, event: SessionEvent) => { + if (event.type !== 'agent-preset/selected') return + queue.push(frame({ + type: 'host/session-preset-changed', + sessionId: session.id, + agentPreset: event.data.agentPreset, + })) + }), ctx.on('settings/document-updated', (ns) => { // The RAW-section event, not the resolved one: a field going from // inherited to overridden leaves the resolved value equal, and a diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index b432880810..fc841f9edb 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -82,6 +82,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }), z.object({ type: z.literal('host/commands-changed') }), + z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }), z.object({ type: z.literal('host/settings-changed'), ns: z.string() }), z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }), z.object({ type: z.literal('host/models-changed') }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index bbf895625f..43607816b3 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -130,6 +130,17 @@ export type HostFrame = * background rather than diffing. */ | { type: 'host/commands-changed' } + /** + * One blank session was recomposed onto another agent preset (the logged + * `agent-preset/selected` commit point, read off the session stream). The + * registry-wide `host/commands-changed` cannot stand in for it: recomposing + * re-parents that agent's scope without registering anything, so a + * preset already mounted for another session produces no registry change + * at all. Clients refetch the catalogs this session's composition decides + * (`command.list`, `skill.list`) for this sessionId alone; the preset id + * rides along for surfaces that label the session. + */ + | { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string } /** * One settings namespace's resolved value changed (`settings/updated` * passthrough) — an RPC write, an external `settings.yaml` edit, or a diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 24f08bae21..444cd5490e 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -14,6 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { RpcId, type RpcRequest } from '../src/api/rpc.ts' +import type { HostFrame } from '../src/api/events.ts' import { InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' @@ -350,6 +351,37 @@ describe('agentPreset.select', () => { .toBe('core-web') }) + it('frames the committed switch so clients can drop that session\'s catalogs', async () => { + const { api, ctx } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' })) + // The host-stream opener reads the committed-workspace baseline; this + // spec owns preset identity, so the stub suffices (api-proxy-commands + // precedent). + ctx.provide('workspace', { list: () => [] } as never) + const abort = new AbortController() + const frames: HostFrame[] = [] + const stream = api.events.host(request({}), abort.signal) + const consume = (async () => { + for await (const frame of stream) { + if (frame.payload.type === 'host/session-preset-changed') frames.push(frame.payload) + } + })() + + await api.agentPresets.select( + request({ sessionId: SessionId('sel-frame'), agentPreset: 'minimal' })) + // The queue push rides the synchronous append, so one turn of the loop is + // enough to deliver it; closing the stream bounds the read either way. + await new Promise(resolve => setTimeout(resolve, 0)) + abort.abort() + await consume + + // Recomposing registers nothing, so this frame — not the registry-wide + // commands one — is what tells a client its cached catalogs are stale. + expect(frames).toEqual([ + { type: 'host/session-preset-changed', sessionId: 'sel-frame', agentPreset: 'minimal' }, + ]) + }) + it('serializes two concurrent selects on one session', async () => { const { api, ctx } = await harness(['standard', 'core-web']) await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 9824a637bf..f398305fa7 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -493,6 +493,7 @@ describe('events frame schemas', () => { } }, { type: 'host/workspace-removed', workspaceId: 'w' }, { type: 'host/commands-changed' }, + { type: 'host/session-preset-changed', sessionId: 's', agentPreset: 'minimal' }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, ] for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 745f44ed0a..63cf4f43ae 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -180,6 +180,7 @@ export const EVENT_WALK_EXEMPTIONS: Record<string, string> = { 'credentials/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface', 'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the surface', 'models/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface', + 'session/preset-changed': 'client-face per-session catalog invalidation signal — packages/client/runtime/README.md owns the surface', 'settings/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface', 'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface', 'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface', From 6a830f1779657b901e017984273a354a2ecd4aeb Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 14:44:37 +0800 Subject: [PATCH 078/229] test(web): stabilize preset-aware steering snapshots --- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 2 ++ apps/web/tests/snapshots/steer-all/settled.expected.md | 2 ++ apps/web/tests/steering.e2e.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 998ee98129..222097e0a3 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index a61f57572e..d9f1893344 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 8a09582b56..a527081fbe 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -314,6 +314,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) + await page.getByText('标准模式', { exact: true }).waitFor({ timeout: 10_000 }) }, 120_000) afterAll(async () => { From 517367854331f6a9c87f05e746abb783a6badadb Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Mon, 10 Aug 2026 14:45:34 +0800 Subject: [PATCH 079/229] review: symmetric policy-service type imports, drop stale inprocess peers, pin child-switch and fork-default cases - child-agent.ts declares both policy-service augmentations as explicit empty type imports, so removing the ApprovalPolicy import cannot silently degrade ctx.get('approval') typing. - dsh-subagent-inprocess no longer consumes the policy services in src, so its optional peers and tsconfig references are dropped; both policy-inheritance Agent Notes state the current ownership. - The continuable suite pins that a later child-side switch beats the delegation snapshot and that an unswitched fork parent seeds no policy events. --- ...7-25-subagent-policy-inheritance.i18n.yaml | 4 +-- .../2026-07-25-subagent-policy-inheritance.md | 2 +- ...26-07-25-subagent-policy-inheritance.zh.md | 2 +- ...able-subagent-policy-inheritance.i18n.yaml | 4 +-- ...continuable-subagent-policy-inheritance.md | 2 +- ...tinuable-subagent-policy-inheritance.zh.md | 2 +- .../subagent/subagent-inprocess/package.json | 10 ------ .../subagent/subagent-inprocess/tsconfig.json | 6 ---- packages/subagent/subagent/src/child-agent.ts | 3 ++ .../tests/continuation-inheritance.spec.ts | 36 +++++++++++++++++++ 10 files changed, 47 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index 616a45e1d1..48074dd7bf 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md -2026-07-25-subagent-policy-inheritance.md: a2f4d578de857ee63df5e7741c433210d5f1ef86 -2026-07-25-subagent-policy-inheritance.zh.md: f069bf290586447afc0b7d46a41ad4de9bfcbe8f +2026-07-25-subagent-policy-inheritance.md: 910581a595f48b356eea9c6242a06159c52b3854 +2026-07-25-subagent-policy-inheritance.zh.md: a0edb3c6beb59a9fe8fdfb801ee718f7c034c296 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index a2f4d578de..910581a595 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -33,4 +33,4 @@ A confined child gets the ordinary denial marker. No answerer currently owns an - Spawn, fork, and nested in-process children retain a parent's explicit sandbox and approval overrides. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, the live-event boundary, default omission, and context disposal. - The keyless headless snapshot is the assembled regression: only the parent is `read-only`, the deployment default is `workspace-write`, and the child's persisted event plus denied disk write both fail if capture is removed. -- Each delegation adds at most two log-only events. `dsh-subagent` and `dsh-subagent-inprocess` have optional peer types for the two policy services; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches. +- Each delegation adds at most two log-only events. `dsh-subagent` owns the optional peer types for the two policy services — its shared helpers hold the `ctx.get` consumption; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index f069bf2905..a0edb3c6be 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -33,4 +33,4 @@ Status: implemented - spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱与审批覆盖项。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、实时事件边界、默认值省略与上下文释放。 - 无密钥 headless 快照是组装后应用层面的回归测试:只有父级是 `read-only`,部署默认值是 `workspace-write`;若移除捕获,子 agent 的持久化事件与被拒的磁盘写入这两项检查都会失败。 -- 每次委派最多增加两条仅日志事件。`dsh-subagent` 和 `dsh-subagent-inprocess` 为两个策略服务提供可选 peer 类型;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。 +- 每次委派最多增加两条仅日志事件。两个策略服务的可选 peer 类型由 `dsh-subagent` 拥有——其共享辅助函数持有 `ctx.get` 消费;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。 diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml index dc23421912..54bc9adfb4 100644 --- a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md -2026-08-10-continuable-subagent-policy-inheritance.md: 39df910a920e6995ba6048fdd2613d2c216f5ec2 -2026-08-10-continuable-subagent-policy-inheritance.zh.md: 2a977eaa9aade189213fdd20a7d888e8e62efb48 +2026-08-10-continuable-subagent-policy-inheritance.md: 04bcd0329a4608445b672d75b6c1e56dd265b25a +2026-08-10-continuable-subagent-policy-inheritance.zh.md: 9ef457df814ed848b04f42c5891024a0890bc9dd diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md index 39df910a92..04bcd0329a 100644 --- a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md @@ -24,6 +24,6 @@ The capture/append pair moved from the one-shot driver into the seam's shared ch ## Consequences - Default-bundle background delegation (`backgroundMode: continuable`) now inherits a parent's explicit sandbox and approval overrides; compositions without either policy service behave unchanged. -- `dsh-subagent` gains optional peer types on `dsh-sandbox-policy` and `dsh-user-approval` (the `ctx.get` pattern the one-shot driver used); `dsh-subagent-inprocess` keeps its optional peers but delegates to the shared helpers. +- `dsh-subagent` gains optional peer types on `dsh-sandbox-policy` and `dsh-user-approval` (the `ctx.get` pattern the one-shot driver used); `dsh-subagent-inprocess` drops its policy-service peers and type imports entirely and delegates to the shared helpers. - The continuable suite (`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`) pins fresh-start seeding, pre-await capture, default omission, cold-resume snapshot stability, and fork-seed precedence; the ACP snapshot scenario `subagent-continuable-inheritance` pins the child's delegation event and read-only runtime context through the assembled app and fails when the capture is removed. - Out-of-process providers (`acp`, `dsh-sdk`, `claude-code`, `codex`) support no continuable children (`prepareContinuable` absent), and their one-shot children keep their own deployment policy (`inheritsParentContext = false`); cross-process policy propagation remains out of scope. diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md index 2a977eaa9a..9ef457df81 100644 --- a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md @@ -24,6 +24,6 @@ Status: implemented ## 后果 - 默认组合包的后台委派(`backgroundMode: continuable`)现在会继承父级显式的沙箱与审批覆盖项;未组合任一策略服务的组合保持原有行为。 -- `dsh-subagent` 新增针对 `dsh-sandbox-policy` 与 `dsh-user-approval` 的可选 peer 类型(即一次性驱动器所用的 `ctx.get` 模式);`dsh-subagent-inprocess` 保留自己的可选 peer,但委托给共享辅助函数。 +- `dsh-subagent` 新增针对 `dsh-sandbox-policy` 与 `dsh-user-approval` 的可选 peer 类型(即一次性驱动器所用的 `ctx.get` 模式);`dsh-subagent-inprocess` 完全移除自己的策略服务 peer 与类型导入,委托给共享辅助函数。 - 可继续测试套件(`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`)锁定全新启动的种子写入、await 前捕获、默认值省略、冷恢复快照稳定性与 fork 种子优先级;ACP 快照场景 `subagent-continuable-inheritance` 经组装后的应用锁定子级的委派事件与只读运行时上下文,移除捕获时即失败。 - 进程外提供方(`acp`、`dsh-sdk`、`claude-code`、`codex`)不支持可继续子 agent(没有 `prepareContinuable`),其一次性子 agent 保留自身的部署策略(`inheritsParentContext = false`);跨进程策略传播仍不在范围内。 diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index e18752db74..2bdbe85234 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -28,22 +28,12 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, - "peerDependenciesMeta": { - "@deepseek-ai/dsh-sandbox-policy": { - "optional": true - }, - "@deepseek-ai/dsh-user-approval": { - "optional": true - } - }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", diff --git a/packages/subagent/subagent-inprocess/tsconfig.json b/packages/subagent/subagent-inprocess/tsconfig.json index 23406e362e..02fd8e53d0 100644 --- a/packages/subagent/subagent-inprocess/tsconfig.json +++ b/packages/subagent/subagent-inprocess/tsconfig.json @@ -32,14 +32,8 @@ { "path": "../../core/tools" }, - { - "path": "../../sandbox/sandbox-policy" - }, { "path": "../../support/invariants" - }, - { - "path": "../../interaction/user-approval" } ] } diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index cb4e8fbd97..878b831dd5 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -17,7 +17,10 @@ import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' // Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve // to the policy services when composed — delegation consumes both // opportunistically (the documented `ctx.get` pattern), never as a hard dep. +// The user-approval side stays an explicit empty import so its augmentation +// does not ride the `ApprovalPolicy` import above. import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-user-approval' import { delegationDepthOf } from './depth.ts' /** Thrown when starting a child would exceed the requested depth cap. */ diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index 246bafa1ed..92cefa261e 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -124,6 +124,42 @@ describe('continuable policy inheritance', () => { expect(policyEvents(loaded.events)).toEqual([]) }) + it('does not freeze deployment defaults into an unswitched fork child either', async () => { + const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('forked child')]) + parent.followup(createUserMessage({ + content: [{ type: 'text', text: 'parent work' }], + source: { kind: 'user' }, + })) + await parent.whenIdle() + + const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.meta.seedLength).toBeGreaterThan(0) + expect(policyEvents(loaded.events)).toEqual([]) + }) + + it('lets a later child-side switch win over the delegation snapshot', async () => { + const { ctx, parent } = await setup([textResponse('child done')]) + setSandboxMode(parent.session, 'danger-full-access') + let child: Agent | undefined + ctx.on('agent/created', ({ agent }) => { + if (agent !== parent) child = agent + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + if (child === undefined) throw new Error('expected the continuable child to be created') + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access') + // Last event wins: the child's own runtime switch beats the seeded snapshot. + setSandboxMode(child.session, 'read-only') + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') + + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + }) + it('cold-resumes on the persisted snapshot without re-capturing the parent', async () => { const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')]) setSandboxMode(parent.session, 'read-only') From e56c6234d2f4cdaa517f875fbddbc3b7c36181d3 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Mon, 10 Aug 2026 14:49:30 +0800 Subject: [PATCH 080/229] chore: exclude archived Agent Notes from rg --- .../process/2026-07-26-frozen-agent-note-archive.i18n.yaml | 4 ++-- .../process/2026-07-26-frozen-agent-note-archive.md | 6 +++++- .../process/2026-07-26-frozen-agent-note-archive.zh.md | 6 +++++- .rgignore | 2 ++ 4 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 .rgignore diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml index 1ae66cc1b7..df1a615d35 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md -2026-07-26-frozen-agent-note-archive.md: 52b43088b276c0c8e263fc8a81a2df1408cc8059 -2026-07-26-frozen-agent-note-archive.zh.md: a37e06e7cbc19b5000d4849cc3ee2ddc9b451ee3 +2026-07-26-frozen-agent-note-archive.md: 0c139c4a5d892de5bdace76b4935edf32586c4c1 +2026-07-26-frozen-agent-note-archive.zh.md: e67f981e5d7ae9d800b725e5ecbaf15dd7fef46b diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md index 52b43088b2..0c139c4a5d 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md @@ -14,6 +14,8 @@ Only implemented Agent Notes can be archived. An implemented note moves when its The archive uses `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`; the redundant `implemented` segment is absent. The archival change moves the complete English, Chinese, and consistency-sidecar triplet, leaves `Status: implemented` intact, and inserts `Archived: YYYY-MM-DD` immediately below it in both language files. Relocation, that metadata line, the corresponding sidecar re-record, and mechanical inbound-link repair are the only permitted archival edits. +The root `.rgignore` excludes the archive from searches that traverse a parent directory. Historical queries name the archive directory explicitly, so intentional access remains available without mixing frozen facts into active decision discovery. + After archival, the triplet is permanently frozen and is historical context rather than current authority. It is not updated for renamed packages, changed behavior, translation standards, formatting rules, broken outbound links, or later documentation contracts. Active prose may intentionally link into an archived note, redirect that link to current authority, or delete it. Repository gates therefore validate links into archived files but never treat archived files as link sources. [`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) owns the frozen boundary. It accepts only the closed set of Agent Note kinds, requires a complete triplet with implemented status and matching valid archive dates, verifies the sidecar against both current Git blob hashes, and seals every artifact by path and SHA-256 content hash in an append-only manifest. Its `--write` mode first proves every existing seal unchanged and then appends only newly archived artifacts. Pull-request CI supplies the trusted base SHA and checks out complete history before running the verifier, so a reused runner's shallow checkout cannot omit the baseline manifest. The ordinary Agent Note format, translation-pairing, wrapping, Markdown-link, package-path, Mermaid, documentation-TypeScript, and type-equivalence gates exclude archive sources; their evolving standards cannot create pressure to edit history. @@ -28,6 +30,8 @@ Supersession is checked while a new Agent Note is being written, not deferred to **Keep every implemented and rejected note active.** Rejected because maintenance effort and search noise grow with records that no longer help a future decision. Rejected notes in particular earn retention only by preventing a plausible fallacy. +**Leave archived notes in default repository search results.** Rejected because archived facts may be stale by design and can outrank current results by lexical match. Historical work can search the archive directory explicitly. + **Defer supersession cleanup to periodic corpus audits.** Rejected because the author of a replacement note has the freshest evidence about ownership and overlap. Postponement leaves redundant active authorities and makes later classification more expensive. **Archive rejected or proposed notes too.** Rejected because archive status means “implemented historical decision.” An obsolete proposal needs an explicit rejection, while a rejection with no guardrail value needs deletion rather than a second low-value holding area. @@ -38,4 +42,4 @@ Supersession is checked while a new Agent Note is being written, not deferred to ## Consequences -The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains searchable and linkable without consuming maintenance attention. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. +The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains explicitly searchable and linkable without consuming maintenance attention or appearing in parent-directory searches. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md index a37e06e7cb..e67f981e5d 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md @@ -14,6 +14,8 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 归档路径为 `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`,其中省略了冗余的 `implemented` 层级。归档变更会移动完整的英文、中文和一致性伴随记录三个文件,保留 `Status: implemented`,并在两种语言的文件中紧接该状态行插入 `Archived: YYYY-MM-DD`。归档时只允许做文件迁移、添加该元数据行、相应地重新记录伴随记录,以及机械修复入站链接。 +根目录的 `.rgignore` 会将归档目录排除在从上层目录开始的搜索之外。查找历史内容时会显式指定归档目录,因此仍可按需访问,同时不会把冻结事实混入对活跃决策的检索。 + 归档后,这三个文件永久冻结,只作为历史背景,不再是当前权威依据。不得因为包重命名、行为变化、翻译标准、格式规则、出站链接失效或后续文档约定而更新归档文件。活跃文档可以有意链接到归档 Agent Note,也可以把该链接重定向到当前权威依据,或直接删除。仓库门禁因此会校验指向归档文件的链接,但绝不把归档文件作为链接源来校验。 [`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) 负责维护冻结边界。它只接受封闭集合中的 Agent Note 类别,要求三个配对文件完整、状态为 implemented,且归档日期有效并互相匹配;它还会用双方当前的 Git blob hash 校验伴随记录,并在仅追加的 manifest(元数据清单) 中按路径和 SHA-256 内容 hash 封存每项产物。其 `--write` 模式会先证明每条现有封存记录对应的内容都未改变,再仅追加新归档的产物。拉取请求 CI 会提供可信的基准 SHA,并在运行校验器前检出完整历史,因此复用运行器上的浅克隆检出无法漏掉基线 manifest。普通的 Agent Note 格式、翻译配对、换行、Markdown 链接、包路径、Mermaid、文档 TypeScript 和类型等价门禁都排除归档源文件,因此这些门禁持续演进的标准不会产生修改历史记录的压力。 @@ -28,6 +30,8 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 **继续将每一份 implemented 和 rejected Agent Note 作为活跃记录保留。** 不予采纳,因为不再帮助未来决策的记录会不断增加维护成本和搜索噪声。尤其是 rejected Agent Note,只有能避免一种可能发生的谬误时,才值得保留。 +**让归档 Agent Note 继续出现在默认的仓库搜索结果中。** 不予采纳,因为归档事实按设计可能已经陈旧,并可能仅凭字面匹配就排在当前结果之前。需要查找历史内容时,可以显式搜索归档目录。 + **把取代关系清理留到定期审计记录集合时再做。** 不予采纳,因为替代记录的作者掌握着关于归属和重叠的最新证据。推迟处理会留下冗余的活跃权威依据,并增加日后分类的成本。 **同时归档 rejected 或 proposed Agent Note。** 不予采纳,因为归档状态表达的是「已经实施的历史决策」。过时的提案需要明确转为 rejected;无法提供防错价值的 rejected Agent Note 则应删除,而不是再放入第二个低价值存放区。 @@ -38,4 +42,4 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 ## 后果 -活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可搜索和链接,却不再消耗维护精力。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent 必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 +活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可显式搜索和链接,却不再消耗维护精力,也不会出现在从上层目录开始的搜索中。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent 必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 diff --git a/.rgignore b/.rgignore new file mode 100644 index 0000000000..6bffdc1726 --- /dev/null +++ b/.rgignore @@ -0,0 +1,2 @@ +# Frozen Agent Notes are historical snapshots, not current search authority. +/.agents/notes/archived/ From f9b7a31ee287f9c4eec733acf3d80032a53d71a1 Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Mon, 10 Aug 2026 14:52:16 +0800 Subject: [PATCH 081/229] docs: regenerate module graph for moved policy-service edges --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 9 +++++---- docs/module-graph.zh.md | 9 +++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 54453b3411..8fa40f2e65 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: c41db02165740b19a9ef751e6f50316a76df28c3 -module-graph.zh.md: 9071dbc0f2e6df8ec7edd1f3e14cd7ff063123fa +module-graph.md: 58e4ce7c23de28a85bf140b426b4ca43d8d18cf7 +module-graph.zh.md: f8605d0c94023fcf51c38e6a8d4cca80ae436220 diff --git a/docs/module-graph.md b/docs/module-graph.md index c41db02165..58e4ce7c23 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -809,6 +809,8 @@ flowchart TD pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_llm + pkg_subagent --> pkg_sandbox + pkg_subagent --> pkg_sandbox_policy pkg_subagent --> pkg_scope pkg_subagent --> pkg_session pkg_subagent --> pkg_session_persistence @@ -816,6 +818,7 @@ flowchart TD pkg_subagent --> pkg_session_projection_cache pkg_subagent --> pkg_tasks pkg_subagent --> pkg_tools + pkg_subagent --> pkg_user_approval pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -999,12 +1002,10 @@ flowchart TD pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm - pkg_subagent_inprocess --> pkg_sandbox_policy pkg_subagent_inprocess --> pkg_session pkg_subagent_inprocess --> pkg_subagent pkg_subagent_inprocess --> pkg_system_prompt pkg_subagent_inprocess --> pkg_tools - pkg_subagent_inprocess --> pkg_user_approval pkg_tool_subagent --> pkg_agent pkg_tool_subagent --> pkg_invariants pkg_tool_subagent --> pkg_llm @@ -1366,7 +1367,7 @@ flowchart TD | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | @@ -1397,7 +1398,7 @@ flowchart TD | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 9071dbc0f2..f8605d0c94 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -811,6 +811,8 @@ flowchart TD pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_llm + pkg_subagent --> pkg_sandbox + pkg_subagent --> pkg_sandbox_policy pkg_subagent --> pkg_scope pkg_subagent --> pkg_session pkg_subagent --> pkg_session_persistence @@ -818,6 +820,7 @@ flowchart TD pkg_subagent --> pkg_session_projection_cache pkg_subagent --> pkg_tasks pkg_subagent --> pkg_tools + pkg_subagent --> pkg_user_approval pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -1001,12 +1004,10 @@ flowchart TD pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm - pkg_subagent_inprocess --> pkg_sandbox_policy pkg_subagent_inprocess --> pkg_session pkg_subagent_inprocess --> pkg_subagent pkg_subagent_inprocess --> pkg_system_prompt pkg_subagent_inprocess --> pkg_tools - pkg_subagent_inprocess --> pkg_user_approval pkg_tool_subagent --> pkg_agent pkg_tool_subagent --> pkg_invariants pkg_tool_subagent --> pkg_llm @@ -1368,7 +1369,7 @@ flowchart TD | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | @@ -1399,7 +1400,7 @@ flowchart TD | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | From 0be9bf312ad3bdbeaaf3ab21a8399a20bfb02b73 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 15:12:07 +0800 Subject: [PATCH 082/229] fix(web): fold the preset frame into the session row for every client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frame carried `agentPreset` for surfaces that label the session, but nothing consumed it: `noteAgentPreset` ran only in the switching tab's RPC callback, so a second connected client refetched its catalogs while its session row — the header label's source, and the hero chip's no-op input — kept the composition the session had replaced. `SessionManager.handleHostEnvelope` now folds the frame like the other session frames. Re-applying the switching tab's own frame is a no-op: the merge lowers `blank` only and keeps the row's `updatedAt`. --- ...lash-catalog-follows-preset-switch.i18n.yaml | 4 ++-- ...08-10-slash-catalog-follows-preset-switch.md | 6 ++++-- ...10-slash-catalog-follows-preset-switch.zh.md | 6 ++++-- apps/web/tests/agent-preset-selection.e2e.ts | 8 +++++--- packages/client/runtime/README.i18n.yaml | 4 ++-- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../runtime/src/client/sessions/manager.ts | 8 ++++++++ .../runtime/tests/sessions-service.spec.ts | 17 +++++++++++++++++ .../client/ui-command/src/client/service.ts | 2 +- packages/host/apiproxy/src/api/events.ts | 5 +++-- 11 files changed, 48 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml index 38cd8786b5..55fc08bfb9 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-slash-catalog-follows-preset-switch.md -2026-08-10-slash-catalog-follows-preset-switch.md: 4f32347e04e9b1cde024a59a32fcfd3cca64172a -2026-08-10-slash-catalog-follows-preset-switch.zh.md: fb30df74a93a9eb913dc43b43c3065c6255bcab9 +2026-08-10-slash-catalog-follows-preset-switch.md: 85bd5b2134fd20c86fdeb13f3ce5b007449105b5 +2026-08-10-slash-catalog-follows-preset-switch.zh.md: 97c8f08a7b3dfec7c17fbb00bef626e28505c500 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md index 4f32347e04..85bd5b2134 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md @@ -16,7 +16,9 @@ The menu therefore kept serving the composition the session no longer ran. Switc The switch's commit point is the logged `agent-preset/selected` event. The host stream frames it as `host/session-preset-changed { sessionId, agentPreset }`, the browser runtime bridges that frame to the typed `session/preset-changed` ctx event beside the registry-invalidation bridges it already owns, and each catalog owner drops its own entry for that session: `ui-command` soft-refreshes the key (the old snapshot keeps serving the open menu until the new one lands), `ui-skill` invalidates it (aborting an in-flight prewarm, so a warm racing the switch cannot publish the stale catalog). -The frame is per session and carries no catalog. Deriving it from the logged event rather than from the RPC handler's return keeps one authority for "this session's composition changed": every connected client observes the switch, not only the tab that issued it, and a client that is not the switcher never has to infer it from a registry signal that will not come. +The frame is per session and carries no catalog, only the preset id — which the manager folds into the session row, because the `agentPresets.select` echo reaches only the client that issued the switch and the row is what the session header labels itself from (and what the hero chip compares the next pick against). + +Deriving the frame from the logged event rather than from the RPC handler's return keeps one authority for "this session's composition changed": every connected client observes the switch, not only the tab that issued it, and a client that is not the switcher never has to infer it from a registry signal that will not come. ## Alternatives considered @@ -38,4 +40,4 @@ That e2e also stopped reading its staged-pick assertion off the serialized sessi ## Related -Reaching the host on a SECOND switch is a separate defect with its own cause and fix: [the session-row identity guard](2026-08-10-session-row-identity-covers-the-preset.md). Until it landed, the e2e below could only exercise the first switch — the invalidation edge here is direction-blind, but the switch it reacts to has to happen. +Reaching the host on a SECOND switch is a separate defect with its own cause and fix: [the session-row identity guard](2026-08-10-session-row-identity-covers-the-preset.md). Until it landed, `agent-preset-selection.e2e.ts` could only exercise the first switch — the invalidation edge here is direction-blind, but the switch it reacts to has to happen. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md index fb30df74a9..97c8f08a7b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md @@ -16,7 +16,9 @@ preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿 这次切换的提交点是落账的 `agent-preset/selected` 事件。宿主流把它成帧为 `host/session-preset-changed { sessionId, agentPreset }`,浏览器运行时在它已经拥有的那组注册表失效桥接旁,把该帧桥接为类型化的 `session/preset-changed` ctx 事件,两份目录各自丢弃该会话的那一项:`ui-command` 软刷新该键(新快照落地前,旧快照继续服务已打开的菜单),`ui-skill` 让它失效(并中止在途的预热,使一次与切换赛跑的 warm 无法发布过期目录)。 -该帧按会话粒度,且不携带目录。从落账事件而不是 RPC 处理器的返回值派生它,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。 +该帧按会话粒度,不携带目录,只带 preset id——manager 会把它折进会话行,因为 `agentPresets.select` 的回执只会到达发起切换的那个客户端,而会话头部标签正是以这一行为准(hero chip 比较下一次选择时读的也是它)。 + +从落账事件而不是 RPC 处理器的返回值派生该帧,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。 ## Alternatives considered @@ -38,4 +40,4 @@ preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿 ## Related -第二次切换能否到达宿主是另一个缺陷,有各自的成因与修复:[会话行的标识判定](2026-08-10-session-row-identity-covers-the-preset.md)。在它落地之前,下面那条 e2e 只能演练第一次切换——这里的失效边对方向无感,但它所响应的那次切换必须真的发生。 +第二次切换能否到达宿主是另一个缺陷,有各自的成因与修复:[会话行的标识判定](2026-08-10-session-row-identity-covers-the-preset.md)。在它落地之前,`agent-preset-selection.e2e.ts` 只能演练第一次切换——这里的失效边对方向无感,但它所响应的那次切换必须真的发生。 diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 71e1a28b05..2bcaa5e628 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -173,12 +173,14 @@ describe('web e2e: agent-preset selection', () => { }) it('re-reads the slash catalog through the composition the switch installed', async () => { + // Continues the previous case: the chip has already applied `minimal` to + // the blank session, and this one reads the menu that switch left behind. onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog')) const composer = page.locator('textarea:enabled').last() - // `minimal` (applied above) mounts neither the compaction group nor plan - // mode nor local skill discovery, so the catalog the composer warmed - // under the deployment default must not survive the switch. + // `minimal` mounts neither the compaction group nor plan mode nor local + // skill discovery, so the catalog the composer warmed under the + // deployment default must not survive the switch. await composer.fill('/') await expect.poll(() => menuOptions(page), { timeout: 15_000 }) .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 29d5a2d4dc..16e055f0bf 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: b1c0c8e5b6aa93f5e79b4b75c5f8db89bd656688 -README.zh.md: b6add06324bf9fc5cf4a93d89d88072609a67c50 +README.md: 753d1de796ba8ff20217d423555710429e9b7a75 +README.zh.md: 9b5b8ba7ce42875afd4b9b83b9c2f64e95298ca5 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index b1c0c8e5b6..753d1de796 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ 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. 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 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. ## Slot declaration injection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index b6add06324..9b5b8ba7ce 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [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.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 对象以及 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` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 ## Slot 声明注入 diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index ce61351cca..fd601090bf 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -780,6 +780,14 @@ export class SessionManager { } return } + case 'host/session-preset-changed': { + // Every connected client observes the switch here; only the tab that + // issued it also gets the RPC echo. The merge keeps the row's own + // updatedAt and lowers `blank` only, so re-applying the switching + // tab's own frame is a no-op. + this.noteAgentPreset(frame.sessionId, frame.agentPreset) + return + } case 'host/session-removed': { const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId) const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId) diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index e7d702f40a..3b25ff849c 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -87,6 +87,23 @@ describe('list store projection', () => { expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal') }) + it('learns a preset switch from the host frame, not only from the tab that issued it', async () => { + const b = bench() + await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }]) + + // Every connected client gets this frame; only the switching tab gets the + // RPC echo. A client that ignored the payload would keep labelling the + // session with the composition it replaced. + b.svc.handleHostEnvelope({ + rpcId: 'r1' as never, + payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never, + }) + await Promise.resolve() + + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal') + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true) + }) + it('reflects live increments (host stream via manager) into the store', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index f17f3950d2..866ff89c9d 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -124,7 +124,7 @@ export class CommandService extends Service implements CommandServiceContract { warm: (session) => { this.directory.warm(session.sessionId) }, }), 'command: slash source') ctx.on('commands/changed', () => { this.directory.invalidateAll() }) - // A preset switch changes which commands ONE session's agent resolves and + // A preset switch changes which commands one session's agent resolves and // registers nothing globally, so the registry-wide signal above never // fires for it: repull that key alone, soft, so the old snapshot serves // the menu until the new one lands. diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 43607816b3..351ea4115e 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -137,8 +137,9 @@ export type HostFrame = * re-parents that agent's scope without registering anything, so a * preset already mounted for another session produces no registry change * at all. Clients refetch the catalogs this session's composition decides - * (`command.list`, `skill.list`) for this sessionId alone; the preset id - * rides along for surfaces that label the session. + * (`command.list`, `skill.list`) for this sessionId alone, and fold the + * preset id into their session row — the RPC echo reaches only the client + * that issued the switch, so the row is where every other one learns it. */ | { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string } /** From 298ae62a173078e1134363bcf5f7e5028068af72 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 15:16:53 +0800 Subject: [PATCH 083/229] docs(scripts): name the event-matrix collector's client-face blind spot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated matrix under-reports client-face listeners because the program is seeded from the host aggregate alone, so `session/preset-changed` lists `ui-command` without `ui-skill` — the same shape as the existing `connection/reset` and `models/changed` rows. Record it where the collector lives, with what closing it actually takes. --- scripts/gen-doc-graphs.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 26e9233f82..cbb73fdcce 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -734,7 +734,18 @@ type CallSiteIndex = Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExp */ const EVENT_API_METHODS = new Set(['on', 'once', 'emit', 'parallel', 'serial', 'waterfall', 'dispatch']) -/** Collect event dispatch/listener relations from real cross-file receiver types. */ +/** + * Collect event dispatch/listener relations from real cross-file receiver types. + * + * TODO: the program is seeded from the host aggregate alone (ts-project.ts + * documents why: one program cannot hold both faces' Context merges), so a + * Client package enters only when a host file imports it. Client-face + * listeners on client-face events are therefore under-reported — + * `connection/reset` omits `ui-skill`/`ui-agent-preset`, `models/changed` + * omits `ui-model`, `session/preset-changed` omits `ui-skill`. Closing it + * needs a second Client program whose relations merge into these, not a + * wider seed. + */ export class EventRelationCollector { private readonly relations = new Map<string, EventRelation>() private readonly fileCallSites = new Map<ts.SourceFile, CallSiteIndex>() From 37ebe87087c75d628e551775d90247ae211dbe23 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 15:27:10 +0800 Subject: [PATCH 084/229] fix(tool-tasks): claim completion notices only for the mount's own scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the task registry to the host plane put every preset's `tool-tasks` listener on ONE `LocalTaskService`. `settle()` computes a single snapshot and walks every registered listener with no scope filter, and it marks `reported` only when a waiter is present — so a task settling without a waiter reached each mount's listener with `reported` false and every one of them injected the same completion into the same owner. Three shipped presets carry `tool-tasks`, and a preset file edit adds a second generation of the same mount, so an agent read N copies of one notice as model-visible durable context. A mount now claims an owner only when the owner's scope chain reaches the mount's own scope. An unscoped mount is the host-plane instance that serves every agent, which keeps the TUI composition and every existing test intact. Registry-side ownership was the alternative: mark `reported` once the first listener claims it. It is wrong because `onTaskDone` is not a notice-only seam — the `dsh-tasks` invariant companion registers a validating listener — so first-claim-wins would silence observers that are not delivering anything. The regression test mounts two scoped `tool-tasks` over one registry and settles an unowned-wait task, which is the only path that reaches the notice listeners at all: the shipped-composition e2e uses `wait: true`, and a waiter marks `reported` before settlement, so that test structurally cannot cover it. Also corrects the standing-mounts Agent Note, which still listed `tasks-local` among the stateful PRESET plugins. Refs #2141 --- ...08-08-per-preset-standing-mounts.i18n.yaml | 4 +- .../2026-08-08-per-preset-standing-mounts.md | 2 +- ...026-08-08-per-preset-standing-mounts.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- packages/tasks/tool-tasks/README.i18n.yaml | 4 +- packages/tasks/tool-tasks/README.md | 2 + packages/tasks/tool-tasks/README.zh.md | 2 + packages/tasks/tool-tasks/package.json | 2 + packages/tasks/tool-tasks/src/index.ts | 12 ++++- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 49 +++++++++++++++++++ packages/tasks/tool-tasks/tsconfig.json | 3 ++ pnpm-lock.yaml | 3 ++ 17 files changed, 88 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml index d8c55c9f0a..410e209e28 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md -2026-08-08-per-preset-standing-mounts.md: 834d645f5f293a2e137b8faf662e301f1e8bb971 -2026-08-08-per-preset-standing-mounts.zh.md: 45ce0f4e7dec28e5bf807898dc9cdbf32b8e4eb5 +2026-08-08-per-preset-standing-mounts.md: c2792454f90a88cd6fba36eed8e36104e5fffea4 +2026-08-08-per-preset-standing-mounts.zh.md: 47668c8c2c424eb188aa14bf55986d27bcfb8ee0 diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md index 834d645f5f..c2792454f9 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md @@ -16,7 +16,7 @@ A preset is one composition per PROCESS, not one per session. The roster mounts Standing mounts fix the class, not the instances: the registrations a reader needs exist for the process lifetime, keyed by preset id, no agent required. What made it cheap -- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`, `tasks-local`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite. +- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite. `tasks-local` shared that property and has since left the preset plane entirely: producers outside its realm (`tool-bash`, `tool-pty`, a non-continuable `tool-subagent`) resolve the registry with `ctx.get`, which an entry-local realm hides from them, so it is composed on the host plane and only the model-facing `tool-tasks` row stays per preset. - Preset ymls are unchanged: one mount per preset = one Entry per preset, whose entry-local realms (`isolate: <name>: true`) keep two presets' same-named services apart exactly as they kept two sessions' apart. - A shared realm label was NOT an option: `provide()` throws on a second registration under the same realm symbol, so labels pool the REALM, never the instance — a per-session world sharing a label crashes the second mount. diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md index 45ce0f4e7d..47668c8c2c 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md @@ -16,7 +16,7 @@ Status: implemented 常驻挂载修的是这一类问题而非其中的个例:读取方需要的注册在进程生命周期内始终存在,按 preset id 索引,不需要任何 agent。让它便宜的原因: -- 有状态的 preset 插件(`plan-mode`、`token-meter`、`compact-basic`、`tasks-local`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。 +- 有状态的 preset 插件(`plan-mode`、`token-meter`、`compact-basic`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。`tasks-local` 同样具备该性质,且此后已完全离开 preset 平面:realm 之外的生产方(`tool-bash`、`tool-pty`、非 continuable 的 `tool-subagent`)以 `ctx.get` 解析该注册表,而 entry-local realm 对它们不可见,因此它组合在宿主平面,只有面向模型的 `tool-tasks` 行仍留在各 preset 中。 - preset 的 yml 不变:每 preset 挂一次 = 每 preset 一个 Entry,其 entry 本地 realm(`isolate: <name>: true`)让两个 preset 的同名服务互不相干,正如它从前隔开两个会话。 - 共享 realm label **不是**选项:`provide()` 对同一 realm 符号下的第二次注册直接抛错,label 池化的是 REALM 而非实例——按会话挂载的世界里共享 label 会让第二次挂载崩溃。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 60c6e85cca..680f6afa42 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: 18980d22c694647374b9fa4e6dfbf245ff2416c4 -config-catalog.zh.md: a43c561806498ca53a95af815d0cd7686a0100ca +config-catalog.md: f4d275393dd918f1391d33db19222e4e62e80b96 +config-catalog.zh.md: 9b3cf689c8d4c23d1cca1260b7e74c911558d678 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 18980d22c6..f4d275393d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2332,7 +2332,7 @@ export interface Config { } ``` -Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) +Source: [`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-todo` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a43c561806..9b3cf689c8 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2333,7 +2333,7 @@ export interface Config { } ``` -来源:[`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) +来源:[`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-todo` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 54453b3411..229c8d1cd6 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: c41db02165740b19a9ef751e6f50316a76df28c3 -module-graph.zh.md: 9071dbc0f2e6df8ec7edd1f3e14cd7ff063123fa +module-graph.md: 0f76bfd2dd700d81e2c6fb6faec3d2c0c9655e98 +module-graph.zh.md: 7fa0eb72666e63e72109a272cdc9ce323c60d2fd diff --git a/docs/module-graph.md b/docs/module-graph.md index c41db02165..0f76bfd2dd 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -947,6 +947,7 @@ flowchart TD pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention + pkg_tool_tasks --> pkg_scope pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools @@ -1390,7 +1391,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 9071dbc0f2..7fa0eb7266 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -949,6 +949,7 @@ flowchart TD pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention + pkg_tool_tasks --> pkg_scope pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools @@ -1392,7 +1393,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/packages/tasks/tool-tasks/README.i18n.yaml b/packages/tasks/tool-tasks/README.i18n.yaml index 24e6b6892a..8c97357246 100644 --- a/packages/tasks/tool-tasks/README.i18n.yaml +++ b/packages/tasks/tool-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/tool-tasks/README.md -README.md: 6e8e889c2330d6991cb384674b011e4d2e988268 -README.zh.md: 355b6736b476fb2434f17ea3857544f32c40adb3 +README.md: 1b63ba7124e9bdbfbf64d70e36e90d1ff13a27c8 +README.zh.md: 946ba9156c4a9d8902f8c47deb6056b2f6525f86 diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 6e8e889c23..1b63ba7124 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -20,6 +20,8 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill` An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's next-step inbox. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. Injection is durable pending context for a later pre-step claim, not a wake-up; cancellation or owner disposal may discard it before claim. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice. +One host registry may carry several mounts of this plugin — one per agent preset — and the registry broadcasts each settlement to every mount. A scoped mount delivers only to owners composed under its own scope, so an agent reads exactly one notice per completion however many presets are mounted; an unscoped mount is the host-plane instance and delivers to every owner. + ## Config | key | default | meaning | diff --git a/packages/tasks/tool-tasks/README.zh.md b/packages/tasks/tool-tasks/README.zh.md index 355b6736b4..946ba9156c 100644 --- a/packages/tasks/tool-tasks/README.zh.md +++ b/packages/tasks/tool-tasks/README.zh.md @@ -20,6 +20,8 @@ 一项尚未报告的完成会把 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` 注入到确切所有者的 next-step inbox。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail,因此通知仍可操作。注入是等待后续 pre-step 领取的持久上下文,并非唤醒;取消或 owner 释放可能在领取前丢弃它。kill 或针对已终止任务的 read/wait 会把交付标为已报告,并抑制重复通知。 +一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份——而注册表会把每次结算广播给全部挂载。带 scope 的挂载只向在其自身 scope 下组合出的所有者交付,因此无论挂载了多少 preset,一个 agent 每次完成都只读到一条通知;不带 scope 的挂载是宿主平面实例,向每个所有者交付。 + ## 配置 | key | 默认值 | 含义 | diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json index dfffbf4855..b910dd6a39 100644 --- a/packages/tasks/tool-tasks/package.json +++ b/packages/tasks/tool-tasks/package.json @@ -32,6 +32,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-retention": "^0.0.1", + "@deepseek-ai/dsh-scope": "^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", @@ -42,6 +43,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index 5372fffdb2..68e8a68ba5 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -10,6 +10,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { TextRetainer } from '@deepseek-ai/dsh-retention' +import { scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' import { TaskId } from '@deepseek-ai/dsh-tasks' @@ -226,12 +227,21 @@ export function apply(ctx: Context, config: Config): void { text: 'Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task\'s work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.', }) - // Use the exact lifecycle owner; reusable ids could resolve to a replacement. // Delivery targets the exact lifecycle owner. The notice waits in its // next-step inbox until another step claims it; disposal before that // boundary discards it with the owner. + // + // One host registry can carry SEVERAL mounts of this plugin — one per agent + // preset — and `settle()` broadcasts a single snapshot to every registered + // listener with no scope filter of its own. Each mount must therefore claim + // only the owners composed under it, or every mounted preset injects the + // same completion into the same agent and the model reads N copies of one + // notice. An unscoped mount is the host-plane instance that serves every + // agent, so it claims all of them. + const mountScope = scopeOf(ctx) ctx.tasks.onTaskDone((snapshot, owner) => { if (snapshot.reported || owner === undefined) return + if (mountScope !== undefined && !scopeChainOf(scopeOf(owner.ctx)).includes(mountScope)) return owner.inject(createUserMessage({ content: [{ type: 'text', diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index 0f1bce7ecf..34b0581c12 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -6,6 +6,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' +import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import { TaskId } from '@deepseek-ai/dsh-tasks' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' @@ -445,6 +446,54 @@ describe('tool-owned UI presentation (presentCall)', () => { }) }) +describe('completion notices across scoped mounts', () => { + /** + * Two agent presets mounting `tool-tasks` over ONE host registry: each mount + * registers its own `onTaskDone` listener on the shared service, and + * `settle()` broadcasts one snapshot to every listener with no scope filter. + * Only the mount whose scope the owner belongs to may deliver the notice. + */ + it('delivers one notice from the owning scope when two mounts share the registry', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + + const standingA = createScope(ctx, {}) + const standingB = createScope(ctx, {}) + await standingA.ctx.plugin(ToolTasks) + await standingB.ctx.plugin(ToolTasks) + + // The agent joins preset A exactly as `agentPresets.compose` binds it. + const agentKey = {} + const agentScope = createScope(ctx, agentKey) + bindScopeParent(agentKey, scopeOf(standingA.ctx) as object) + + const inject = vi.fn() + const owner = { + id: SessionId('sess-scoped'), + ctx: agentScope.ctx, + inject, + session: { id: SessionId('sess-scoped'), header: { version: 0, id: SessionId('sess-scoped'), createdAt: 0 } }, + } as unknown as Agent + const dispose = ctx.agents.register(owner) + + try { + // No waiter: `settle()` leaves `reported` false, which is the only path + // that reaches the notice listeners at all. + const p = producer({ owner, label: 'pnpm test' }) + ctx.tasks.start(p.spec) + p.settle({ status: 'completed', detail: 'exit code: 0' }) + await tick() + + expect(inject).toHaveBeenCalledTimes(1) + } finally { + dispose() + } + }) +}) + describe('completion notices', () => { it('injects a notice into the owning agent when an unreported task settles', async () => { const { ctx } = await setup() diff --git a/packages/tasks/tool-tasks/tsconfig.json b/packages/tasks/tool-tasks/tsconfig.json index cff642796c..497860371f 100644 --- a/packages/tasks/tool-tasks/tsconfig.json +++ b/packages/tasks/tool-tasks/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../core/scope" + }, { "path": "../../core/tools" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 072064f91e..4f00b4f41d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6900,6 +6900,9 @@ importers: '@deepseek-ai/dsh-retention': specifier: workspace:^ version: link:../../util/retention + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 34c5da26b883477cb8ecf99e2d5bc7c30810d165 Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Mon, 10 Aug 2026 15:53:15 +0800 Subject: [PATCH 085/229] test: align apiproxy model harness with path-only workspaces --- packages/host/apiproxy/tests/api-proxy-models.spec.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index ff3a775a2b..bdd21128b4 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -156,7 +156,6 @@ describe('Web session model selection', () => { const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', - workspaceRoot: '/tmp', }) const result = await api.sessions.prompt(request({ @@ -203,7 +202,6 @@ describe('Web session model selection', () => { const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', - workspaceRoot: '/tmp', }) const image = { type: 'image' as const, @@ -246,7 +244,6 @@ describe('Web session model selection', () => { const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', - workspaceRoot: '/tmp', }) agent.session.append('agent/inbox/spliced', { target: 'next-turn', From ea47c3280504c21ba795e2d20b514add43251751 Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Mon, 10 Aug 2026 16:09:04 +0800 Subject: [PATCH 086/229] 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<void> | undefined const disposeProcess = (): Promise<void> => (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<void> { 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<SubagentRun>` 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<SubagentRun>` 是所有权转移边界;委派工具也会在其由 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<SubagentResult>() 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 <id>`, 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 <childId>`. 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 <id>`,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent <childId>`。可继续路由在 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<ContentBlock, { type: 'text' }> => 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<ForegroundToolResu run.result.then((result): ForegroundToolResult => { 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 59e759ce13738fe7e61fd468a509d3f07de079bc Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 16:12:41 +0800 Subject: [PATCH 087/229] fix(tasks-local): layer control surfaces and listeners by registering scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One host registry serves every composition in the process, so its two service-wide collections answered per-owner questions process-wide. `start()` asked only whether SOME surface was attached, so an agent whose own composition loads no `tool-tasks` could start work it has no tool to collect or stop as soon as any other preset attached one — and the answer changed depending on which sessions happened to be open. `settle()` walked every registered listener, so a task settling without a waiter injected one completion notice per mounted preset into the same owner. Both collections now sit in `ScopedLayers`, the layered-registry primitive `tools` and `skills` already use: a registration files into its registering context's scope, and a read unions the global layer with the owner's scope chain. A surface or listener registered from an unscoped context lands in the global layer and serves every owner, which is exactly the host-plane composition's own controls, so the TUI path is unchanged without a special case. This supersedes the consumer-side filter in the previous commit. That filter produced the right notices but sat in the wrong layer: it left the `start()` gate process-wide, it could not be enforced against a producer that resolves the registry directly, and it made a Consumer carry scope knowledge that the other layered registries keep in the registry. `tool-tasks` is scope-agnostic again and the `dsh-scope` edge moves to `tasks-local`. `start()`'s refusal is now owner-relative, so its model-visible text names the agent rather than the process. The shipped `minimal` preset keeps `enableRunInBackground: false`, no longer as the safety boundary — the registry owns that now — but so an agent that could never collect a task is not offered the parameter at all. Refs #2141 --- .../2026-07-26-task-registry-seam.i18n.yaml | 4 +- .../2026-07-26-task-registry-seam.md | 2 +- .../2026-07-26-task-registry-seam.zh.md | 2 +- .../agent-presets/minimal/agent.cordis.yml | 10 +-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 6 +- docs/module-graph.zh.md | 6 +- docs/subsystems/tasks.i18n.yaml | 4 +- docs/subsystems/tasks.md | 16 ++-- docs/subsystems/tasks.zh.md | 16 ++-- packages/bash/tool-bash/tests/tools.spec.ts | 2 +- packages/bash/tool-pwsh/tests/tools.spec.ts | 2 +- .../tool-cordis/src/api-catalog.ts | 4 +- .../tool-subagent/tests/tool-subagent.spec.ts | 2 +- packages/tasks/tasks-local/README.i18n.yaml | 4 +- packages/tasks/tasks-local/README.md | 2 + packages/tasks/tasks-local/README.zh.md | 2 + packages/tasks/tasks-local/package.json | 2 + packages/tasks/tasks-local/src/index.ts | 87 ++++++++++++++++--- .../tasks/tasks-local/tests/tasks.spec.ts | 73 ++++++++++++++-- packages/tasks/tasks-local/tsconfig.json | 3 + packages/tasks/tasks/README.i18n.yaml | 4 +- packages/tasks/tasks/README.md | 4 +- packages/tasks/tasks/README.zh.md | 4 +- packages/tasks/tasks/src/index.ts | 21 +++-- packages/tasks/tool-tasks/README.i18n.yaml | 4 +- packages/tasks/tool-tasks/README.md | 2 +- packages/tasks/tool-tasks/README.zh.md | 2 +- packages/tasks/tool-tasks/package.json | 2 - packages/tasks/tool-tasks/src/index.ts | 14 +-- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 2 +- packages/tasks/tool-tasks/tsconfig.json | 3 - pnpm-lock.yaml | 6 +- 36 files changed, 232 insertions(+), 97 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml index fc8cf85334..5236b342a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md -2026-07-26-task-registry-seam.md: 45801505f1729ec6094900acf94b4c17ed92c3b1 -2026-07-26-task-registry-seam.zh.md: 8dd90b34d2da1d22caba13fe8c388dab4a29be0d +2026-07-26-task-registry-seam.md: 4487bd9c53595fa8b4eed588b294ceafe3ab58dc +2026-07-26-task-registry-seam.zh.md: 6195dc809e84852c7e0f63ac101ba0ed6a46853e diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md index 45801505f1..4487bd9c53 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md @@ -12,7 +12,7 @@ The [background-task runtime](2026-06-20-generic-long-running-tool-runtime.md) s `tasks/` is now a three-package capability family in the bash-trio shape: -- **`@deepseek-ai/dsh-tasks` (Service Definition)** — the abstract `TaskService extends Service` owning `ctx.tasks`, the eight-method contract (`start`, `list`, `get`, `read`, `kill`, `wait`, `onTaskDone`, `attachSurface`), all vocabulary types (`TaskId`, `TaskKindMap`, `TaskStart`, `TaskHooks`, `TaskOutcome`, `TaskSnapshot`, `TaskRead`, `TaskDoneListener`), and the snapshot invariant companion. The class-level JSDoc states the semantics every Service provider owes: registrations outlive producer and surface fibers, owned access is session-fenced, settlement is first-wins with contained listeners, and `start` refuses work while no control surface is attached. +- **`@deepseek-ai/dsh-tasks` (Service Definition)** — the abstract `TaskService extends Service` owning `ctx.tasks`, the eight-method contract (`start`, `list`, `get`, `read`, `kill`, `wait`, `onTaskDone`, `attachSurface`), all vocabulary types (`TaskId`, `TaskKindMap`, `TaskStart`, `TaskHooks`, `TaskOutcome`, `TaskSnapshot`, `TaskRead`, `TaskDoneListener`), and the snapshot invariant companion. The class-level JSDoc states the semantics every Service provider owes: registrations outlive producer and surface fibers, owned access is session-fenced, settlement is first-wins with contained listeners, and `start` refuses work while no attached control surface serves the spec's owner (surfaces and listeners are scope-layered, so one process-wide registry answers both questions per owner). - **`@deepseek-ai/dsh-tasks-local` (Service provider)** — `LocalTaskService`, the process-local registry moved verbatim: the in-memory store, per-kind counters, waiter bookkeeping, `TASK_WAIT_TIMEOUT` deadline code, owner-cleanup effects, and force-fail teardown. The `dsh-timeout` dependency moves here with it; the Service Definition package has no provider dependencies. - **`@deepseek-ai/dsh-tool-tasks` (Consumer)** — unchanged; it injects `'tasks'` and never imports provider types. diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md index 8dd90b34d2..6195dc809e 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md @@ -12,7 +12,7 @@ Status: implemented `tasks/` 如今是一个 bash 三件套形态的三包能力家族: -- **`@deepseek-ai/dsh-tasks`(Service Definition)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、八个方法的约定(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`attachSurface`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个 Service provider 都必须兑现的语义:注册的存续期长于生产方与控制接口的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且在没有附加任何控制接口时 `start` 拒绝启动工作。 +- **`@deepseek-ai/dsh-tasks`(Service Definition)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、八个方法的约定(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`attachSurface`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个 Service provider 都必须兑现的语义:注册的存续期长于生产方与控制接口的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且当没有任何已附加的控制接口服务于 spec 的所有者时 `start` 拒绝启动工作(控制接口与监听器按 scope 分层,因此一个进程级注册表能逐所有者地回答这两个问题)。 - **`@deepseek-ai/dsh-tasks-local`(Service provider)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除。`dsh-timeout` 依赖随之迁入此包;Service Definition 包不含任何提供方依赖。 - **`@deepseek-ai/dsh-tool-tasks`(Consumer)**——保持不变;它注入 `'tasks'`,从不导入提供方类型。 diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index cbccafe160..6ae88b9339 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -23,11 +23,11 @@ # from here; the executor behind it (`bash-sandbox`) is host-plane too, where the # sandbox policy owns it. # -# `run_in_background` is off because this preset mounts no `tool-tasks`: the -# host task registry gates starts on SOME control surface being attached, and -# that set is process-wide, so another preset's controls would let this agent -# start work it has no `task_output` to collect. Disabling drops the parameter -# from the schema too, which is the honest surface for a two-tool benchmark. +# `run_in_background` is off because this preset mounts no `tool-tasks`. The +# host registry already refuses a start for an owner no attached control +# surface serves, so this is not the safety boundary — it is the model-facing +# one: an agent that could never collect a task should not be offered the +# parameter at all, and disabling it drops the parameter from the schema. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' config: diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 680f6afa42..60c6e85cca 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: f4d275393dd918f1391d33db19222e4e62e80b96 -config-catalog.zh.md: 9b3cf689c8d4c23d1cca1260b7e74c911558d678 +config-catalog.md: 18980d22c694647374b9fa4e6dfbf245ff2416c4 +config-catalog.zh.md: a43c561806498ca53a95af815d0cd7686a0100ca diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f4d275393d..18980d22c6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2332,7 +2332,7 @@ export interface Config { } ``` -Source: [`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts) +Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-todo` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9b3cf689c8..a43c561806 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2333,7 +2333,7 @@ export interface Config { } ``` -来源:[`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts) +来源:[`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-todo` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 229c8d1cd6..e973aeacf7 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: 0f76bfd2dd700d81e2c6fb6faec3d2c0c9655e98 -module-graph.zh.md: 7fa0eb72666e63e72109a272cdc9ce323c60d2fd +module-graph.md: 9cb734066beb6c8f721c57d8d2cae29ff299ae66 +module-graph.zh.md: 3005ce3143d2ba89e2808e048b18224c438ece60 diff --git a/docs/module-graph.md b/docs/module-graph.md index 0f76bfd2dd..9cb734066b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -730,6 +730,7 @@ flowchart TD pkg_session_title_llm --> pkg_timeout pkg_tasks_local --> pkg_agent pkg_tasks_local --> pkg_invariants + pkg_tasks_local --> pkg_scope pkg_tasks_local --> pkg_tasks pkg_tasks_local --> pkg_timeout pkg_token_meter --> pkg_compact @@ -947,7 +948,6 @@ flowchart TD pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention - pkg_tool_tasks --> pkg_scope pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools @@ -1353,7 +1353,7 @@ flowchart TD | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | -| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | +| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1391,7 +1391,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 7fa0eb7266..3005ce3143 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -732,6 +732,7 @@ flowchart TD pkg_session_title_llm --> pkg_timeout pkg_tasks_local --> pkg_agent pkg_tasks_local --> pkg_invariants + pkg_tasks_local --> pkg_scope pkg_tasks_local --> pkg_tasks pkg_tasks_local --> pkg_timeout pkg_token_meter --> pkg_compact @@ -949,7 +950,6 @@ flowchart TD pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention - pkg_tool_tasks --> pkg_scope pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools @@ -1355,7 +1355,7 @@ flowchart TD | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | -| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | +| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1393,7 +1393,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index c229d0a5c0..37b776d609 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: d3e92891a6736a85519b97775ebe7aa6f12a5ae2 -tasks.zh.md: fe54d7a6482743a8f2ef31b143edb25afe0a478b +tasks.md: 51b21b6d81905f1dede2a1c748a417425d2aaf4e +tasks.zh.md: 045f4e438b2b384439826ecca0782349d7aaae27 diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index d3e92891a6..51b21b6d81 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -172,7 +172,7 @@ Implementations must honor these semantics: - Registrations outlive producer and control-surface fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. - Owned-task access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. - Settlement is first-wins: one terminal record, one round of contained listener notification, and released waiters, even against a late producer outcome. -- start refuses work while no control surface is attached, so a producer cannot start work that callers cannot collect or stop. +- start refuses work while no attached control surface serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. ```ts cordis-catalog /** @@ -237,17 +237,19 @@ abstract kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'alrea abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot> /** - * Register an effect-scoped completion listener. Each listener is contained; - * returned promises are observed but not awaited. No listener runs after - * service disposal. + * Register an effect-scoped completion listener. It receives the settlements + * of the owners its registering context's scope covers; each listener is + * contained; returned promises are observed but not awaited. No listener runs + * after service disposal. * @param listener - receives each terminal snapshot and its exact owner. * @returns disposer that unregisters the listener. */ abstract onTaskDone(listener: TaskDoneListener): () => void /** - * Attach an effect-scoped surface that can read and stop tasks. {@link start} - * refuses work while none is attached. + * Attach an effect-scoped surface that can read and stop tasks. It serves the + * owners its registering context's scope covers, and {@link start} refuses an + * owner no attached surface serves. * @param name - diagnostic label; duplicate names remain independent. * @returns disposer that detaches this surface. */ @@ -256,5 +258,5 @@ abstract attachSurface(name: string): () => void Types: [Agent](core.md) -Source: [`packages/tasks/tasks/src/index.ts:50`](../../packages/tasks/tasks/src/index.ts) +Source: [`packages/tasks/tasks/src/index.ts:55`](../../packages/tasks/tasks/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index fe54d7a648..045f4e438b 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -172,7 +172,7 @@ Implementations must honor these semantics: - Registrations outlive producer and control-surface fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. - Owned-task access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. - Settlement is first-wins: one terminal record, one round of contained listener notification, and released waiters, even against a late producer outcome. -- start refuses work while no control surface is attached, so a producer cannot start work that callers cannot collect or stop. +- start refuses work while no attached control surface serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. ```ts cordis-catalog /** @@ -237,17 +237,19 @@ abstract kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'alrea abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot> /** - * Register an effect-scoped completion listener. Each listener is contained; - * returned promises are observed but not awaited. No listener runs after - * service disposal. + * Register an effect-scoped completion listener. It receives the settlements + * of the owners its registering context's scope covers; each listener is + * contained; returned promises are observed but not awaited. No listener runs + * after service disposal. * @param listener - receives each terminal snapshot and its exact owner. * @returns disposer that unregisters the listener. */ abstract onTaskDone(listener: TaskDoneListener): () => void /** - * Attach an effect-scoped surface that can read and stop tasks. {@link start} - * refuses work while none is attached. + * Attach an effect-scoped surface that can read and stop tasks. It serves the + * owners its registering context's scope covers, and {@link start} refuses an + * owner no attached surface serves. * @param name - diagnostic label; duplicate names remain independent. * @returns disposer that detaches this surface. */ @@ -256,5 +258,5 @@ abstract attachSurface(name: string): () => void Types: [Agent](core.md) -Source: [`packages/tasks/tasks/src/index.ts:50`](../../packages/tasks/tasks/src/index.ts) +Source: [`packages/tasks/tasks/src/index.ts:55`](../../packages/tasks/tasks/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 4f913d78ee..9a1698ac1d 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -536,7 +536,7 @@ describe('background execution through the task runtime', () => { const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) expect(result.isError).toBe(true) - expect(text(result)).toContain('no control surface is attached') + expect(text(result)).toContain('no control surface serves this agent') // Declare-then-execute: the failed preflight means no process ever ran. expect((ctx.bash as CountingStartExecutor).starts).toBe(0) }) diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 91ad796ce5..7ecdbcd5f2 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -777,7 +777,7 @@ describe('background execution through the task runtime', () => { const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) expect(result.isError).toBe(true) - expect(text(result)).toContain('no control surface is attached') + expect(text(result)).toContain('no control surface serves this agent') // Declare-then-execute: the failed preflight means no process ever ran. expect(bash.startCalls).toBe(0) }) diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 86def75601..de53d4ae47 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1122,11 +1122,11 @@ 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 */', + jsDoc: '/**\n * Register an effect-scoped completion listener. It receives the settlements\n * of the owners its registering context\'s scope covers; each listener is\n * contained; returned promises are observed but not awaited. No listener runs\n * after service disposal.\n * @param listener - receives each terminal snapshot and its exact owner.\n * @returns disposer that unregisters the listener.\n */', }, { signature: 'abstract 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 */', + jsDoc: '/**\n * Attach an effect-scoped surface that can read and stop tasks. It serves the\n * owners its registering context\'s scope covers, and {@link start} refuses an\n * owner no attached surface serves.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */', }, ], }, diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 91dc423cd4..04b127a856 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -1027,7 +1027,7 @@ describe('background preflight failure (no orphaned child, by construction)', () agent: parent, }) expect(result.isError).toBe(true) - expect(text(result)).toContain('no control surface is attached') + expect(text(result)).toContain('no control surface serves this agent') // Declare-then-execute: the failed preflight means no child ever existed. expect(starts).toBe(0) }) diff --git a/packages/tasks/tasks-local/README.i18n.yaml b/packages/tasks/tasks-local/README.i18n.yaml index d4fa5d09bf..3a43cba8f8 100644 --- a/packages/tasks/tasks-local/README.i18n.yaml +++ b/packages/tasks/tasks-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/tasks/tasks-local/README.md -README.md: 663ce0d333c6df0f84900a2570d5487d8d7abe55 -README.zh.md: a5d1acaa50f63dc95b7607657b157272c15a4f11 +README.md: 80d7932466188955ade1c14e4968d51b739ba818 +README.zh.md: 5e4263e4685be64f91ec2a7c74edbf89e1148866 diff --git a/packages/tasks/tasks-local/README.md b/packages/tasks/tasks-local/README.md index 663ce0d333..80d7932466 100644 --- a/packages/tasks/tasks-local/README.md +++ b/packages/tasks/tasks-local/README.md @@ -12,6 +12,8 @@ Service disposal closes listeners, cancels all live tasks, awaits their records, Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, notifies listeners once with per-listener containment, and releases waiters. Pending waits mark the task reported before listeners run so completion surfaces do not duplicate notices. +Surfaces and listeners are layered by the scope that registered them, in the tools-registry shape: a registration files into its registering context's scope, and a read unions the global layer with the owner's scope chain. One process-wide registry therefore answers per-owner questions per owner — `start()` refuses `background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)` for an owner whose own composition attaches none, however many other compositions attach theirs, and a settlement reaches only the listeners its owner's composition registered. + ## Model Experience Indirectly, through producer plugins and [`dsh-tool-tasks`](../tool-tasks/README.md), which render task ids, output, status, cancellation, and completion notices. diff --git a/packages/tasks/tasks-local/README.zh.md b/packages/tasks/tasks-local/README.zh.md index a5d1acaa50..5e4263e468 100644 --- a/packages/tasks/tasks-local/README.zh.md +++ b/packages/tasks/tasks-local/README.zh.md @@ -12,6 +12,8 @@ 结算遵循首次结算优先原则:最早出现的终止结果(生产方结算、作为 `failed` 隔离处理的 `done` 拒绝,或销毁时的强制失败)只记录一次,也只通知监听器一次;各监听器的故障会单独隔离,随后释放等待方。挂起的等待会在监听器运行前把任务标记为已报告,因此呈现完成情况的表层不会重复发出通知。 +表层与监听器按注册方所在的 scope 分层,形状与 tools 注册表一致:一次注册归档到其注册上下文的 scope,一次读取则把全局层与所有者的 scope 链求并集。因此一个进程级注册表能逐所有者地回答逐所有者的问题——对自身组合未附加任何表层的所有者,无论其他组合附加了多少,`start()` 都会拒绝并抛出 `background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)`;一次结算也只会抵达其所有者所属组合注册的监听器。 + ## 模型体验 通过生产方插件和 [`dsh-tool-tasks`](../tool-tasks/README.md) 间接影响;它们会呈现任务 id、输出、状态、取消和完成通知。 diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json index 446b5825d3..92e095ffa9 100644 --- a/packages/tasks/tasks-local/package.json +++ b/packages/tasks/tasks-local/package.json @@ -27,6 +27,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -35,6 +36,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/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 60a7beb012..146d3e4ca7 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -11,6 +11,8 @@ import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' +import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeLayer } from '@deepseek-ai/dsh-scope' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { TaskService, TaskId } from '@deepseek-ai/dsh-tasks' import type { TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from '@deepseek-ai/dsh-tasks' @@ -49,6 +51,21 @@ function isTerminal(status: TaskStatus): boolean { return status === 'completed' || status === 'killed' || status === 'failed' } +/** + * One scope's contributions: the control surfaces attached from it and the + * completion listeners registered there. Both tables are anonymous because a + * contribution is identified by its own disposer, never by a name a second + * registrant could shadow. + */ +class TaskLayer implements ScopeLayer { + readonly surfaces = new AnonymousEntries<symbol>() + readonly listeners = new AnonymousEntries<TaskDoneListener>() + + isEmpty(): boolean { + return this.surfaces.isEmpty() && this.listeners.isEmpty() + } +} + /** * The in-memory `tasks` registry. See the Service Definition contract in * `@deepseek-ai/dsh-tasks` for the ownership, isolation, and lifecycle @@ -57,8 +74,19 @@ function isTerminal(status: TaskStatus): boolean { export class LocalTaskService extends TaskService { private store = new Map<TaskId, TrackedTask>() private counters = new Map<string, number>() - private surfaces = new Set<symbol>() - private listeners = new Set<TaskDoneListener>() + /** + * Surfaces and listeners layered by the scope that registered them, in the + * tools-registry shape: a contribution files into its registering context's + * scope, and a read unions the global layer with the reader's scope chain. + * + * The registry is one process-wide instance serving every composition, so a + * flat table would answer a per-owner question process-wide: one preset's + * task controls would hold `start()` open for an agent whose own composition + * loads none, and one settlement would reach every preset's notice listener. + * Layers make both reads owner-relative. Nothing derives a cache from a + * layer, so change notification is a no-op. + */ + private readonly layers = new ScopedLayers<TaskLayer>(() => new TaskLayer(), () => {}) private listenersClosed = false /** Owner agents with attached scope cleanup, mapped to the exact disposer. */ private ownerCleanups = new Map<Agent, () => Promise<void> | void>() @@ -72,8 +100,8 @@ export class LocalTaskService extends TaskService { } start(spec: TaskStart): TaskId { - if (this.surfaces.size === 0) { - throw new Error('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)') + if (!this.servesOwner(spec.owner)) { + throw new Error('background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)') } if (spec.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string') if (spec.label.length === 0) throw new Error('invalid task label: expected a non-empty string') @@ -210,23 +238,53 @@ export class LocalTaskService extends TaskService { } onTaskDone(listener: TaskDoneListener): () => void { - const dispose = this.ctx.effect(() => { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - }, 'tasks.onTaskDone()') + const dispose = this.layers.effect( + this.ctx, + layer => layer.listeners.append(listener), + { label: 'tasks.onTaskDone()' }, + ) return () => void dispose() } attachSurface(name: string): () => void { // One token per call keeps duplicate labels independently disposable. const token = Symbol(name) - const dispose = this.ctx.effect(() => { - this.surfaces.add(token) - return () => this.surfaces.delete(token) - }, 'tasks.attachSurface()') + const dispose = this.layers.effect( + this.ctx, + layer => layer.surfaces.append(token), + { label: 'tasks.attachSurface()' }, + ) return () => void dispose() } + /** + * Whether an attached control surface can collect and stop work owned by + * `owner`. The global layer holds every surface attached from an unscoped + * context — a host composition's own controls — and therefore serves every + * owner; a scoped surface serves exactly the agents composed under it. + * @param owner - the task's owner, or undefined for unowned work. + * @returns whether some reachable surface serves the owner. + */ + private servesOwner(owner?: Agent): boolean { + if (!this.layers.global.surfaces.isEmpty()) return true + return this.layers.chainLayers(owner === undefined ? undefined : scopeOf(owner.ctx)) + .some(layer => !layer.surfaces.isEmpty()) + } + + /** + * The completion listeners that own `owner`'s notices: the global layer's + * first, then each scoped layer along the owner's chain. A listener outside + * that chain belongs to another composition and must not deliver, or the + * owner reads one notice per mounted preset. + * @param owner - the settled task's owner, or undefined for unowned work. + * @returns the listeners to notify, in registration order per layer. + */ + private *listenersFor(owner?: Agent): IterableIterator<TaskDoneListener> { + yield* this.layers.global.listeners.values() + const scope = owner === undefined ? undefined : scopeOf(owner.ctx) + for (const layer of this.layers.chainLayers(scope)) yield* layer.listeners.values() + } + /** Look up a task or fail loud. */ private expect(id: TaskId): TrackedTask { const task = this.store.get(id) @@ -276,7 +334,7 @@ export class LocalTaskService extends TaskService { if (task.waiters > 0) task.reported = true if (!this.listenersClosed) { const snapshot = this.snapshot(task) - for (const listener of this.listeners) { + for (const listener of this.listenersFor(task.owner)) { try { const returned = listener(snapshot, task.owner) void Promise.resolve(returned).catch((error: unknown) => { @@ -330,8 +388,9 @@ export class LocalTaskService extends TaskService { * effects. Throwing cancels are force-failed to avoid teardown deadlock. */ private async disposeAll(): Promise<void> { + // The flag is the whole guard: each layer entry's undo belongs to the fiber + // that registered it, so this service may not drop them on its own way out. this.listenersClosed = true - this.listeners.clear() const all = [...this.store.values()] this.cancelForTeardown(all, 'tasks service disposed') await Promise.all(all.map(task => task.settled)) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 29d859760f..bdaa31d975 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -3,6 +3,8 @@ import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' +import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { TaskId } from '@deepseek-ai/dsh-tasks' import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' @@ -15,9 +17,18 @@ declare module '@deepseek-ai/dsh-tasks' { const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>() -function stubAgent(ctx: Context, rawId: string): Agent { +function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { const id = SessionId(rawId) const scopeFiber = ctx.plugin(() => {}) + // `presetScope` reproduces what `agentPresets.compose` does: the agent gets + // its own key parented to the standing mount's, so the registry's chain walk + // reaches that preset's layer. + let agentCtx = scopeFiber.ctx + if (presetScope !== undefined) { + const key = {} + bindScopeParent(key, presetScope) + agentCtx = createScope(scopeFiber.ctx, key).ctx + } const session = Session.create(id) const agent = { id, @@ -25,7 +36,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle' as const, - ctx: scopeFiber.ctx, + ctx: agentCtx, send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), @@ -73,6 +84,21 @@ async function harness() { return ctx } +/** + * Attach a control surface the way `tool-tasks` does: from a plugin whose own + * `inject` resolves `ctx.tasks`, so the service method binds to the REGISTERING + * context and the surface files into that context's scope layer. Reading the + * service off a bare scoped context instead throws `cannot get property "tasks" + * without inject`, which is the same rule the shipped plugin obeys. + * @param ctx - the context whose scope should own the surface. + */ +async function attachSurfaceIn(ctx: Context): Promise<void> { + await ctx.plugin({ + inject: ['tasks'], + apply(pluginCtx: Context) { pluginCtx.tasks.attachSurface('tool-tasks') }, + }) +} + /** Let the settlement continuation (a `done.then`) run. */ const tick = () => new Promise<void>(r => setTimeout(r, 0)) @@ -89,11 +115,48 @@ describe('LocalTaskService.start', () => { expectTypeOf<TaskSnapshot['ownerSession']>().toEqualTypeOf<SessionId | undefined>() }) - it('refuses to register while no control surface is attached', async () => { + it('refuses to register while no control surface serves the owner', async () => { const ctx = new Context() await ctx.plugin(LocalTaskService) expect(() => ctx.tasks.start(producer().spec)) - .toThrow('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)') + .toThrow('background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)') + }) + + it('refuses an owner whose own composition attaches no surface', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + // Two standing preset mounts over one registry; only the first loads the + // task controls. The second must not inherit the first's open gate. + const withControls = createScope(ctx, {}) + const withoutControls = createScope(ctx, {}) + await attachSurfaceIn(withControls.ctx) + + const served = stubAgent(ctx, 'served', scopeOf(withControls.ctx)) + const unserved = stubAgent(ctx, 'unserved', scopeOf(withoutControls.ctx)) + ctx.agents.register(served) + ctx.agents.register(unserved) + + expect(() => ctx.tasks.start(producer({ owner: served }).spec)).not.toThrow() + expect(() => ctx.tasks.start(producer({ owner: unserved }).spec)) + .toThrow('no control surface serves this agent') + // An unowned producer has no chain to walk, so only a global surface serves it. + expect(() => ctx.tasks.start(producer().spec)) + .toThrow('no control surface serves this agent') + }) + + it('lets a surface attached without a scope serve every owner', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + // The host-plane composition's own controls: no scope, so the global layer + // holds them and every owner's read includes it. + await attachSurfaceIn(ctx) + const scoped = stubAgent(ctx, 'scoped', scopeOf(createScope(ctx, {}).ctx)) + ctx.agents.register(scoped) + + expect(() => ctx.tasks.start(producer({ owner: scoped }).spec)).not.toThrow() + expect(() => ctx.tasks.start(producer().spec)).not.toThrow() }) it('rejects an empty kind, empty label, and invalid output limit', async () => { @@ -757,6 +820,6 @@ describe('LocalTaskService disposal', () => { detachA2() expect(() => ctx.tasks.start(producer().spec)).not.toThrow() // b remains await fiber.dispose() // detaches b with its fiber (HMR safety) - expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached') + expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface serves this agent') }) }) diff --git a/packages/tasks/tasks-local/tsconfig.json b/packages/tasks/tasks-local/tsconfig.json index 147e3915bc..4e9a3e20bf 100644 --- a/packages/tasks/tasks-local/tsconfig.json +++ b/packages/tasks/tasks-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/scope" + }, { "path": "../../util/timeout" }, diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml index 94f8993c2c..95bb5a3889 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: 04ca125580104d0cb5ab0ce8cdd20d104c4a1668 -README.zh.md: b0b079566246549c7acab7a83fcc3c21c44ef868 +README.md: f23a93e3cb1fc5aad5bad053f832bb66baa8eb64 +README.zh.md: c3ea9125c4db9f2d86722cbf490b4f0eecfbe25a diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index 04ca125580..f23a93e3cb 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -12,7 +12,9 @@ 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. -- `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when none is attached. +- `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when no attached surface serves the spec's owner. + +Both registrations are owner-relative, because one registry serves every composition in the process. A surface or listener registered from an unscoped context serves every owner; one registered under an agent composition's scope serves exactly the agents composed under it. So a composition that loads no control surface cannot start background work on the strength of another composition's controls, and one settlement notifies only the listeners its owner's composition registered. 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 b0b0795662..c3ea9125c4 100644 --- a/packages/tasks/tasks/README.zh.md +++ b/packages/tasks/tasks/README.zh.md @@ -12,7 +12,9 @@ - `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。 - `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。 - `onTaskDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出的异常和产生的拒绝都会被隔离;系统不会等待监听器工作。 -- `attachSurface(name)` 在其 effect 生命周期内声明控制表层。如果没有附加任何表层,`start()` 会在生产方执行前失败。 +- `attachSurface(name)` 在其 effect 生命周期内声明控制表层。当没有任何已附加的表层服务于 spec 的所有者时,`start()` 会在生产方执行前失败。 + +这两类注册都是相对于所有者的,因为一个注册表要服务进程内的每一套组合。从不带 scope 的上下文注册的表层或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,则恰好服务于在该组合下组合出的 agent。因此,未加载任何控制表层的组合无法借另一套组合的控制工具启动后台工作,而一次结算也只会通知其所有者所属组合注册的监听器。 有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务释放。 diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 5bfbe4e5a8..8c49ec8445 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -44,8 +44,13 @@ declare module 'cordis' { * - Settlement is first-wins: one terminal record, one round of contained * listener notification, and released waiters, even against a late * producer outcome. - * - {@link start} refuses work while no control surface is attached, so a - * producer cannot start work that callers cannot collect or stop. + * - {@link start} refuses work while no attached control surface serves the + * spec's owner, so a producer cannot start work that owner cannot collect + * or stop. One registry serves every composition in the process, so this + * question — and completion-listener delivery — is owner-relative rather + * than process-wide: registrations made from an unscoped context serve + * every owner, and registrations made under an agent composition's scope + * serve exactly the agents composed under it. */ export abstract class TaskService extends Service { constructor(ctx: Context) { @@ -120,17 +125,19 @@ export abstract class TaskService extends Service { abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot> /** - * Register an effect-scoped completion listener. Each listener is contained; - * returned promises are observed but not awaited. No listener runs after - * service disposal. + * Register an effect-scoped completion listener. It receives the settlements + * of the owners its registering context's scope covers; each listener is + * contained; returned promises are observed but not awaited. No listener runs + * after service disposal. * @param listener - receives each terminal snapshot and its exact owner. * @returns disposer that unregisters the listener. */ abstract onTaskDone(listener: TaskDoneListener): () => void /** - * Attach an effect-scoped surface that can read and stop tasks. {@link start} - * refuses work while none is attached. + * Attach an effect-scoped surface that can read and stop tasks. It serves the + * owners its registering context's scope covers, and {@link start} refuses an + * owner no attached surface serves. * @param name - diagnostic label; duplicate names remain independent. * @returns disposer that detaches this surface. */ diff --git a/packages/tasks/tool-tasks/README.i18n.yaml b/packages/tasks/tool-tasks/README.i18n.yaml index 8c97357246..aba9cf38d6 100644 --- a/packages/tasks/tool-tasks/README.i18n.yaml +++ b/packages/tasks/tool-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/tool-tasks/README.md -README.md: 1b63ba7124e9bdbfbf64d70e36e90d1ff13a27c8 -README.zh.md: 946ba9156c4a9d8902f8c47deb6056b2f6525f86 +README.md: 4e8872b087bac6576a3b48acbf079d3f06f3a131 +README.zh.md: 3beeb1b70c3a757f5935b0621c9648e0a02c7aa5 diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 1b63ba7124..4e8872b087 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -20,7 +20,7 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill` An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's next-step inbox. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. Injection is durable pending context for a later pre-step claim, not a wake-up; cancellation or owner disposal may discard it before claim. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice. -One host registry may carry several mounts of this plugin — one per agent preset — and the registry broadcasts each settlement to every mount. A scoped mount delivers only to owners composed under its own scope, so an agent reads exactly one notice per completion however many presets are mounted; an unscoped mount is the host-plane instance and delivers to every owner. +One host registry may carry several mounts of this plugin — one per agent preset. The registry routes each settlement to the listeners the owner's scope chain reaches, so a mount under one preset never sees another preset's agents and an agent reads exactly one notice per completion however many presets are mounted. The same routing decides which agents this mount's control surface serves: an agent whose composition loads no `tool-tasks` cannot start background work at all. ## Config diff --git a/packages/tasks/tool-tasks/README.zh.md b/packages/tasks/tool-tasks/README.zh.md index 946ba9156c..3beeb1b70c 100644 --- a/packages/tasks/tool-tasks/README.zh.md +++ b/packages/tasks/tool-tasks/README.zh.md @@ -20,7 +20,7 @@ 一项尚未报告的完成会把 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` 注入到确切所有者的 next-step inbox。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail,因此通知仍可操作。注入是等待后续 pre-step 领取的持久上下文,并非唤醒;取消或 owner 释放可能在领取前丢弃它。kill 或针对已终止任务的 read/wait 会把交付标为已报告,并抑制重复通知。 -一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份——而注册表会把每次结算广播给全部挂载。带 scope 的挂载只向在其自身 scope 下组合出的所有者交付,因此无论挂载了多少 preset,一个 agent 每次完成都只读到一条通知;不带 scope 的挂载是宿主平面实例,向每个所有者交付。 +一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份。注册表会把每次结算路由给所有者 scope 链所能抵达的监听器,因此某个 preset 下的挂载永远看不到另一个 preset 的 agent,无论挂载了多少 preset,一个 agent 每次完成都只读到一条通知。同一套路由也决定本挂载的控制表层服务哪些 agent:组合中未加载 `tool-tasks` 的 agent 根本无法启动后台工作。 ## 配置 diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json index b910dd6a39..dfffbf4855 100644 --- a/packages/tasks/tool-tasks/package.json +++ b/packages/tasks/tool-tasks/package.json @@ -32,7 +32,6 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-retention": "^0.0.1", - "@deepseek-ai/dsh-scope": "^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", @@ -43,7 +42,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index 68e8a68ba5..720cb51f97 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -10,7 +10,6 @@ import type { Context } from 'cordis' import z from 'schemastery' import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { TextRetainer } from '@deepseek-ai/dsh-retention' -import { scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' import { TaskId } from '@deepseek-ai/dsh-tasks' @@ -227,21 +226,16 @@ export function apply(ctx: Context, config: Config): void { text: 'Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task\'s work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.', }) + // Use the exact lifecycle owner; reusable ids could resolve to a replacement. // Delivery targets the exact lifecycle owner. The notice waits in its // next-step inbox until another step claims it; disposal before that // boundary discards it with the owner. // - // One host registry can carry SEVERAL mounts of this plugin — one per agent - // preset — and `settle()` broadcasts a single snapshot to every registered - // listener with no scope filter of its own. Each mount must therefore claim - // only the owners composed under it, or every mounted preset injects the - // same completion into the same agent and the model reads N copies of one - // notice. An unscoped mount is the host-plane instance that serves every - // agent, so it claims all of them. - const mountScope = scopeOf(ctx) + // The registry routes each settlement to the listeners its owner's scope + // chain reaches, so a mount under one preset never sees another preset's + // agents; this listener owns delivery, not the choice of whom to deliver to. ctx.tasks.onTaskDone((snapshot, owner) => { if (snapshot.reported || owner === undefined) return - if (mountScope !== undefined && !scopeChainOf(scopeOf(owner.ctx)).includes(mountScope)) return owner.inject(createUserMessage({ content: [{ type: 'text', diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index 34b0581c12..a213ca83e5 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -86,7 +86,7 @@ describe('tool-tasks setup', () => { const { ctx, toolsFiber } = await setup() expect(() => ctx.tasks.start(producer().spec)).not.toThrow() await toolsFiber.dispose() - expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached') + expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface serves this agent') }) it('rejects a config whose default wait exceeds the cap', async () => { diff --git a/packages/tasks/tool-tasks/tsconfig.json b/packages/tasks/tool-tasks/tsconfig.json index 497860371f..cff642796c 100644 --- a/packages/tasks/tool-tasks/tsconfig.json +++ b/packages/tasks/tool-tasks/tsconfig.json @@ -26,9 +26,6 @@ { "path": "../../core/system-prompt" }, - { - "path": "../../core/scope" - }, { "path": "../../core/tools" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f00b4f41d..ffb80644fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6869,6 +6869,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -6900,9 +6903,6 @@ importers: '@deepseek-ai/dsh-retention': specifier: workspace:^ version: link:../../util/retention - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From ef35c7f3b227b0640097e8d8c6ffd37bd5c3e323 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 7 Aug 2026 16:01:41 +0800 Subject: [PATCH 088/229] fix(feedback): include session id in acknowledgement --- packages/feedback/command-feedback/README.i18n.yaml | 4 ++-- packages/feedback/command-feedback/README.md | 2 +- packages/feedback/command-feedback/README.zh.md | 2 +- packages/feedback/command-feedback/src/index.ts | 8 ++++++-- .../command-feedback/tests/command-feedback.spec.ts | 6 +++--- .../command-feedback/tests/loader-composition.spec.ts | 5 ++++- 6 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index d919320643..b1b1a8d4d4 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/feedback/command-feedback/README.md -README.md: d7849e25fc62897e4ac6793f40bdc139adf9ba3d -README.zh.md: c3b7b59d90d924de6042aeac1e7eec39457c6c83 +README.md: 96d2825f4b63c95ad6f45ca8b2e05d1fc5ae92aa +README.zh.md: 5220afe68b1f0de50fd1368900758906ee6907c9 diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index d7849e25fc..96d2825f4b 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -8,7 +8,7 @@ Trigger-independent session feedback plus human-facing `/feedback` capture. The | Input | Result | |---|---| -| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded.` | +| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded for session {id}`. | | `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. | Surrounding whitespace is discarded, but feedback is otherwise unparsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own event; nothing is replaced or merged. diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md index c3b7b59d90..5220afe68b 100644 --- a/packages/feedback/command-feedback/README.zh.md +++ b/packages/feedback/command-feedback/README.zh.md @@ -8,7 +8,7 @@ | 输入 | 结果 | |---|---| -| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded.` 确认。 | +| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded for session {id}` 确认。 | | `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 | 前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。 diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index 037a463104..92ef839415 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -41,14 +41,18 @@ export function recordFeedback(session: Session, text: string): void { * Validate, record, and acknowledge one feedback entry. Returning an error * leaves no `feedback/record` event. * @param invocation - receiving agent, raw command input, and UI cancellation. - * @returns an acknowledgement, or a usage error when no feedback text was supplied. + * @returns an acknowledgement containing the receiving session id, or a usage error + * when no feedback text was supplied. */ function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { if (invocation.rawInput.trim().length === 0) { return { kind: 'error', text: `Feedback text is required. ${USAGE}` } } recordFeedback(invocation.agent.session, invocation.rawInput) - return { kind: 'success', text: 'Feedback recorded.' } + return { + kind: 'success', + text: `Feedback recorded for session ${invocation.agent.session.id}`, + } } /** Register the global `/feedback` command for every composed command adapter. */ diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 145b7ccf11..19d886af00 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -93,7 +93,7 @@ describe('/feedback human command', () => { const test = await harness() await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({ kind: 'success', - text: 'Feedback recorded.', + text: `Feedback recorded for session ${test.session.id}`, }) expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable']) const commandRun = test.session.events.find(event => event.type === 'command/run') @@ -141,8 +141,8 @@ describe('/feedback human command', () => { test.ctx.commands.execute(test.agent, '/feedback second', signal), ]) expect(settled.map(item => item?.result)).toEqual([ - { kind: 'success', text: 'Feedback recorded.' }, - { kind: 'success', text: 'Feedback recorded.' }, + { kind: 'success', text: `Feedback recorded for session ${test.session.id}` }, + { kind: 'success', text: `Feedback recorded for session ${test.session.id}` }, ]) expect(feedbackTexts(test.session)).toEqual(['first', 'second']) }) diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 227672d167..98609afdea 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -87,7 +87,10 @@ describe('/feedback real Loader composition through cordis.yml', () => { expect(context.commands.list(owner).map(command => command.name)).toContain('feedback') const accepted = await context.commands.execute(owner, '/feedback the diff view is unreadable', signal) - expect(accepted?.result).toEqual({ kind: 'success', text: 'Feedback recorded.' }) + expect(accepted?.result).toEqual({ + kind: 'success', + text: 'Feedback recorded for session feedback-loader-agent', + }) const rejected = await context.commands.execute(owner, '/feedback', signal) expect(rejected?.result).toEqual({ kind: 'error', From 8dc91d2c00f231d14b962f27b6a6143f36258add Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 7 Aug 2026 16:38:54 +0800 Subject: [PATCH 089/229] fix(feedback): report shared anonymous user id --- ...hared-feedback-telemetry-user-id.i18n.yaml | 6 ++ ...08-07-shared-feedback-telemetry-user-id.md | 33 +++++++++++ ...07-shared-feedback-telemetry-user-id.zh.md | 33 +++++++++++ .../2026-07-28-feedback-command.i18n.yaml | 4 +- .../feature/2026-07-28-feedback-command.md | 4 +- .../feature/2026-07-28-feedback-command.zh.md | 4 +- ...7-31-telemetry-anonymous-user-id.i18n.yaml | 4 +- .../2026-07-31-telemetry-anonymous-user-id.md | 10 ++-- ...26-07-31-telemetry-anonymous-user-id.zh.md | 10 ++-- apps/web/tests/seeded-history.e2e.ts | 55 ++++++++++++++++--- .../seeded-history/feedback-row.expected.md | 53 ++++++++++++++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 13 +++-- docs/module-graph.zh.md | 13 +++-- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- .../command-feedback/README.i18n.yaml | 4 +- packages/feedback/command-feedback/README.md | 4 +- .../feedback/command-feedback/README.zh.md | 4 +- .../feedback/command-feedback/package.json | 2 + .../feedback/command-feedback/src/index.ts | 7 ++- .../tests/command-feedback.spec.ts | 20 +++++-- .../tests/loader-composition.spec.ts | 8 ++- .../feedback/command-feedback/tsconfig.json | 3 + .../session-telemetry-otel/package.json | 6 +- .../session-telemetry-otel/src/index.ts | 2 +- .../session-telemetry-otel/tests/otel.spec.ts | 2 +- .../session-telemetry-otel/tsconfig.json | 5 +- packages/session/user-id/README.i18n.yaml | 6 ++ packages/session/user-id/README.md | 29 ++++++++++ packages/session/user-id/README.zh.md | 29 ++++++++++ packages/session/user-id/package.json | 39 +++++++++++++ .../src/user-id.ts => user-id/src/index.ts} | 26 ++++----- packages/session/user-id/src/invariant.ts | 31 +++++++++++ .../session/user-id/tests/invariant.spec.ts | 12 ++++ .../tests/user-id.spec.ts | 2 +- packages/session/user-id/tsconfig.json | 21 +++++++ pnpm-lock.yaml | 27 +++++++-- .../verify-package-readme-model-experience.ts | 1 + tsconfig.host.json | 1 + 44 files changed, 462 insertions(+), 89 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md create mode 100644 .agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.zh.md create mode 100644 apps/web/tests/snapshots/seeded-history/feedback-row.expected.md create mode 100644 packages/session/user-id/README.i18n.yaml create mode 100644 packages/session/user-id/README.md create mode 100644 packages/session/user-id/README.zh.md create mode 100644 packages/session/user-id/package.json rename packages/session/{session-telemetry-otel/src/user-id.ts => user-id/src/index.ts} (79%) create mode 100644 packages/session/user-id/src/invariant.ts create mode 100644 packages/session/user-id/tests/invariant.spec.ts rename packages/session/{session-telemetry-otel => user-id}/tests/user-id.spec.ts (99%) create mode 100644 packages/session/user-id/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.i18n.yaml new file mode 100644 index 0000000000..226b62f100 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md +2026-08-07-shared-feedback-telemetry-user-id.md: 6d4020828cb1f2ab3de0328c8959a18a0fcfe6c4 +2026-08-07-shared-feedback-telemetry-user-id.zh.md: 892fa0f848d656609885d008ab36e3ebbe09b992 diff --git a/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md new file mode 100644 index 0000000000..6d4020828c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md @@ -0,0 +1,33 @@ +# Agent Note: Shared feedback and telemetry anonymous user id + +Status: implemented + +English | [中文](2026-08-07-shared-feedback-telemetry-user-id.zh.md) + +## Problem + +The OpenTelemetry backend already persisted one anonymous UUID in `$DSH_HOME/.userid`. `/feedback` now needs to report both the receiving session id and a user id so an operator can correlate the acknowledgement with exported records. Duplicating or independently generating that identity would make the reported user meaningless, while importing it from `session-telemetry-otel` would make a direct command depend on an exporter backend and create a dependency cycle when feedback export is mounted by telemetry. + +The earlier [anonymous-user-id decision](../feature/2026-07-31-telemetry-anonymous-user-id.md) deliberately kept the helper inside the OTel backend until a second real consumer existed. Feedback is that consumer. + +## Decision + +`@deepseek-ai/dsh-user-id` owns `getOrCreateAnonymousUserId()` and the `$DSH_HOME/.userid` storage contract. `session-telemetry-otel` uses the returned id as OpenTelemetry Resource `user.id`; the `/feedback` success acknowledgement reports `Feedback recorded for session {sessionId}` followed by `User: {userId}` on a second line, which keeps both identifiers available through the generic command row's expandable body. Invalid feedback is rejected before resolving the id, so an empty command does not create `.userid`. + +The extraction preserves the existing random UUID, home resolution, process memo, exclusive-create concurrency, corruption replacement, and best-effort write semantics. It does not unify the dsh-sdk launcher's separate `telemetry.json` identity. + +## Alternatives considered + +| Rejected | Reason | +|---|---| +| Import the helper from `session-telemetry-otel` | Couples feedback to an optional exporter backend and forms a reverse dependency cycle once telemetry exports feedback | +| Duplicate the persistence helper in feedback | Two implementations of one file contract can drift and race with different validation or failure semantics | +| Generate a separate feedback user id | The acknowledgement could not correlate with the OTel Resource and would not satisfy the reporting purpose | +| Move the launcher telemetry id too | The launcher feed is not a consumer of `.userid`; unifying unrelated stores remains out of scope | + +## Consequences + +- One harness home now has one anonymous id shared by feedback acknowledgements and session telemetry exports. +- The feedback package depends only on the identity capability, not the telemetry seam or OTel SDK. +- The new package is a justified shared seam with two consumers; its empty invariant companion explains why reading the private file is not a useful runtime relationship check. +- The original anonymous-user-id Note remains authoritative for storage and privacy semantics, while this Note supersedes only its OTel-local ownership decision. diff --git a/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.zh.md b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.zh.md new file mode 100644 index 0000000000..892fa0f848 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 反馈与遥测共享匿名用户 id + +Status: implemented + +[English](2026-08-07-shared-feedback-telemetry-user-id.md) | 中文 + +## 问题 + +OpenTelemetry 后端已在 `$DSH_HOME/.userid` 中持久化一个匿名 UUID。`/feedback` 需要同时报告接收反馈的会话 id 与用户 id,以便运维人员将确认文本与导出的记录相关联。复制该身份或单独生成身份会使报告的用户失去意义;从 `session-telemetry-otel` 导入身份则会让直接命令依赖导出后端,并在遥测侧挂载反馈导出时形成依赖环。 + +早先的[匿名用户 id 决策](../feature/2026-07-31-telemetry-anonymous-user-id.md)刻意将辅助函数留在 OTel 后端内,直至出现第二个真实消费方。反馈就是这个消费方。 + +## 决策 + +`@deepseek-ai/dsh-user-id` 负责 `getOrCreateAnonymousUserId()` 和 `$DSH_HOME/.userid` 存储契约。`session-telemetry-otel` 将返回的 id 用作 OpenTelemetry Resource 的 `user.id`;`/feedback` 的成功确认先报告 `Feedback recorded for session {sessionId}`,再在第二行显示 `User: {userId}`,使两个标识符都可通过通用命令行的可展开正文查看。系统在获取 id 前拒绝无效反馈,因此空命令不会创建 `.userid`。 + +此次抽取保留既有的随机 UUID、home 解析、进程内缓存、独占创建并发、损坏文件替换与 best-effort 写入语义。它不会统一 dsh-sdk launcher 独立的 `telemetry.json` 身份。 + +## 考虑过的替代方案 + +| 已否决 | 原因 | +|---|---| +| 从 `session-telemetry-otel` 导入辅助函数 | 使反馈耦合到可选的导出后端,并在遥测导出反馈后形成反向依赖环 | +| 在反馈中复制持久化辅助函数 | 同一文件契约的两份实现可能发生偏差,并因校验或失败语义不同而产生竞态 | +| 生成独立的反馈用户 id | 确认文本无法与 OTel Resource 相关联,因而不能达到报告目的 | +| 同时移动 launcher telemetry id | launcher 回流不是 `.userid` 的消费方;统一无关存储仍不在范围内 | + +## 后果 + +- 一个 harness home 只有一个匿名 id,由反馈确认与会话遥测导出共享。 +- 反馈包只依赖身份能力,不依赖遥测 seam 或 OTel SDK。 +- 新包由两个消费方使用,成为有充分依据的共享 seam;其空不变式伴生插件解释了为何读取私有文件并非有用的运行时关系检查。 +- 原始匿名用户 id Note 仍是存储与隐私语义的权威记录;本 Note 仅取代其中由 OTel 本地拥有身份的决策。 diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml index e0133df46f..809e37044f 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-feedback-command.md -2026-07-28-feedback-command.md: 78cc9e89a5811b3f1520bae2bd971cbcf2522ede -2026-07-28-feedback-command.zh.md: b0bba25ec3331123cb86066fc6c89186f2297cc9 +2026-07-28-feedback-command.md: 3edb29283c289d6d006891a4c19087b01fa8166f +2026-07-28-feedback-command.zh.md: c2513d2570474cbbaf8d94f87603d8ce10d40c14 diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md index 78cc9e89a5..3edb29283c 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md @@ -12,7 +12,7 @@ The capture surface has to be usable at the moment of annoyance, which rules out ## Decision -`@deepseek-ai/dsh-command-feedback` in `packages/feedback/command-feedback/` registers one global `feedback` command over `ctx.commands`. `/feedback <text>` acknowledges; bare or whitespace-only input returns a direct usage error. The handler is synchronous, injects only `commands`, and has no configuration. +`@deepseek-ai/dsh-command-feedback` in `packages/feedback/command-feedback/` registers one global `feedback` command over `ctx.commands`. `/feedback <text>` acknowledges with the receiving session id and the shared harness-home anonymous user id; bare or whitespace-only input returns a direct usage error. The handler is synchronous, injects only `commands`, and has no configuration. [The shared-id decision](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md) records why feedback and OpenTelemetry use the same `$DSH_HOME/.userid` value. The package declares the log-only `feedback/record { text }` session event and exports `recordFeedback(session, text)` as its command-independent producer. The producer discards surrounding whitespace, rejects an empty result, and appends exactly one event. `/feedback` delegates to it, so another UI, hook, or host integration can record the same domain fact without constructing a slash command. @@ -54,7 +54,7 @@ Surrounding whitespace is discarded, but nothing else is parsed. `/feedback /pla ## Consequences -The shipped `dsh` base mounts the command unconditionally — no configuration, no dependency on the goal stack. The Web client exposes it through its command adapter. Headless mode, ACP, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. +The shipped `dsh` base mounts the command unconditionally — no configuration, no dependency on the goal stack. The Web client exposes it through its command adapter. Headless mode, ACP, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. The first accepted feedback for a harness home can create `$DSH_HOME/.userid`; rejected empty input does not resolve or create an id. The package owns one independent append-only event with no cross-event or mutable-data relation for an invariant companion to check. The event follows the session log's existing replay, fork, persistence, and crash-tail behavior. diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md index b0bba25ec3..c2513d2570 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -位于 `packages/feedback/command-feedback/` 的 `@deepseek-ai/dsh-command-feedback` 通过 `ctx.commands` 注册一个全局 `feedback` 命令。`/feedback <text>` 给出确认;空输入或仅含空白的输入返回直接用法错误。处理器是同步的,只注入 `commands`,且没有任何配置。 +位于 `packages/feedback/command-feedback/` 的 `@deepseek-ai/dsh-command-feedback` 通过 `ctx.commands` 注册一个全局 `feedback` 命令。`/feedback <text>` 在确认文本中包含接收反馈的会话 id 与 harness home 的共享匿名用户 id;空输入或仅含空白的输入返回直接用法错误。处理器是同步的,只注入 `commands`,且没有任何配置。[共享 id 决策](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md)说明了反馈与 OpenTelemetry 为何使用同一个 `$DSH_HOME/.userid` 值。 本包声明仅写入日志的 `feedback/record { text }` 会话事件,并导出 `recordFeedback(session, text)`,作为不依赖命令的生产方。该生产方丢弃前后空白,拒绝空结果,并且恰好追加一个事件。`/feedback` 委托给它,因此其他 UI、钩子或 host 集成无需构造斜杠命令也能记录同一个领域事实。 @@ -54,7 +54,7 @@ Status: implemented ## 后果 -随附的 `dsh` 基础组合无条件挂载该命令:没有配置,也不依赖 goal 栈。Web 客户端通过命令适配器暴露该命令。无头模式、ACP 和 JSON-RPC 不提供命令适配器,因此 `/feedback` 在那里不可用。 +随附的 `dsh` 基础组合无条件挂载该命令:没有配置,也不依赖 goal 栈。Web 客户端通过命令适配器暴露该命令。无头模式、ACP 和 JSON-RPC 不提供命令适配器,因此 `/feedback` 在那里不可用。对于某个 harness home,首次接受反馈时可能创建 `$DSH_HOME/.userid`;被拒绝的空输入不会获取或创建 id。 本包拥有一个独立的仅追加事件,不存在跨事件关系或可变数据关系可供不变式伴生插件检查。该事件遵循会话日志现有的回放、fork、持久化和崩溃尾部行为。 diff --git a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml index 47e1e0144b..1bec2a758b 100644 --- a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md -2026-07-31-telemetry-anonymous-user-id.md: f53fc9ce7eb3a5aefcb0be0a20c7403b5601e369 -2026-07-31-telemetry-anonymous-user-id.zh.md: 99b2dd88df94810ccfc85d099e74a6f0852153a7 +2026-07-31-telemetry-anonymous-user-id.md: 75b65e9fd477d19afb3a3a25e424a7f7620099a3 +2026-07-31-telemetry-anonymous-user-id.zh.md: 3db5b665f9cbbe6f884a9717afa758f22a419658 diff --git a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md index f53fc9ce7e..75b65e9fd4 100644 --- a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md +++ b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md @@ -10,7 +10,7 @@ Session telemetry is mounted by default ([default-mount Note](2026-07-31-web-tel ## Decision -The `session-telemetry-otel` package's own module `src/user-id.ts` owns the OTel feed's user identity: `getOrCreateAnonymousUserId()` returns the bare UUID line in `$DSH_HOME/.userid` (resolved by `resolveDshHome`, `$DSH_HOME` > `~/.dsh`), minting and persisting a random UUID v4 on first use; the backend constructor carries it as the Resource's `user.id` (the OTel semconv user attribute), once per export batch. This identity belongs to the OTel feed alone; the dsh-sdk launcher telemetry keeps its own anonymous-id store (`telemetry.json`), and the two are not shared (the first cut unified both feeds through a shared util package — no shared package before a second real consumer exists, revisit when a feed-correlation need appears). +`getOrCreateAnonymousUserId()` returns the bare UUID line in `$DSH_HOME/.userid` (resolved by `resolveDshHome`, `$DSH_HOME` > `~/.dsh`), minting and persisting a random UUID v4 on first use; the backend constructor carries it as the Resource's `user.id` (the OTel semconv user attribute), once per export batch. The original implementation lived inside `session-telemetry-otel` because no second real consumer existed. `/feedback` later became that consumer, so [the shared-id decision](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md) moves ownership to `@deepseek-ai/dsh-user-id` without changing the storage, anonymity, concurrency, or loss semantics recorded here. The dsh-sdk launcher telemetry keeps its own anonymous-id store (`telemetry.json`) and remains unrelated. | Ruling | Value | Rationale | |---|---|---| @@ -22,8 +22,8 @@ The `session-telemetry-otel` package's own module `src/user-id.ts` owns the OTel | Write failure | Best-effort: return the in-memory id | Telemetry is never blocked by a read-only home | | Report position | Resource attribute, not per-record attributes | Once per batch suffices for Resource-dimension aggregation; per-record injection would touch the seam contract and grow the wire | | semconv dependency | `@opentelemetry/semantic-conventions` is not imported | One string constant does not justify a dependency | -| Home | A module inside `session-telemetry-otel`, not a shared util package | Repo rule: split a package only for a second real consumer; the sdk launcher feed keeps its own store, and no real correlation need exists | -| Separate switch | None | Identity follows the telemetry master switch (`DSH_TELEMETRY_DISABLED`); telemetry off means nothing reports | +| Home | `@deepseek-ai/dsh-user-id`, shared by the OTel backend and `/feedback` | The second real consumer now exists; direct feedback must not depend on an exporter backend | +| Separate switch | None | Either consumer can create the identity; `DSH_TELEMETRY_DISABLED` stops telemetry reporting but does not disable feedback acknowledgement | ## Alternatives considered @@ -31,7 +31,7 @@ The `session-telemetry-otel` package's own module `src/user-id.ts` owns the OTel |---|---| | Hostname/IP-hash-derived id (the prior ruling) | Reversible means not anonymous; the random UUID is semantically clean — the user ruled to supersede | | user.id on every record's attributes (Claude Code's shape) | Touches the session-telemetry seam contract or injects per record, growing the wire; once per batch on the Resource already aggregates | -| A shared util package unifying both feeds (the first cut) | The only real consumer is the OTel backend; switching the sdk launcher onto it was unification for its own sake — the user reconsidered and pulled it back, to be re-extracted when a correlation need appears | +| A shared package before `/feedback` needed the id (the first cut) | At that time the only real consumer was the OTel backend; extraction became justified only when direct feedback needed the same correlation id | | Reusing telemetry.json instead of a new file | The file name/JSON format files the identity under the launcher feed's naming; the OTel feed's identity is a standalone fact | | AppCLIEntry reading the id and injecting via config patch | Every surface entry needs wiring; a runtime fact inside deployment config conflates the two | | Housing it in `@deepseek-ai/dsh-paths` | paths is pure path computation with zero IO; a persisting identity capability would pollute the package boundary | @@ -39,6 +39,6 @@ The `session-telemetry-otel` package's own module `src/user-id.ts` owns the OTel ## Consequences - One `$DSH_HOME` is one stable user in the OTel feed; separate homes are separate users by construction, with no cross-home linking mechanism. -- The OTel feed and the launcher feed each hold their own id (`.userid` vs `telemetry.json`) and cannot be correlated — the direct cost of not extracting a shared package, to be unified when a real correlation need appears. +- The OTel feed and `/feedback` share `.userid`; the launcher feed still uses `telemetry.json` and cannot be correlated with them. - Deleting `.userid` resets the identity (effective next launch); on an unwritable home each process holds its own in-memory id until the home becomes writable. - The [default-mount Note](2026-07-31-web-telemetry-default-mount.md)'s identity follow-up is closed for the anonymous-user-id part by this decision; hostname/surface dimensions, the redaction rule, and the usage-metrics track remain open. diff --git a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md index 99b2dd88df..3db5b665f9 100644 --- a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md @@ -10,7 +10,7 @@ session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry ## Decision -`session-telemetry-otel` 包内模块 `src/user-id.ts` 是 OTel 回流用户身份的属主:`getOrCreateAnonymousUserId()` 返回 `$DSH_HOME/.userid`(`resolveDshHome` 解析,`$DSH_HOME` > `~/.dsh`)中的裸 UUID 行,首用生成随机 UUID v4 并落盘;backend 构造时把它作为 Resource 的 `user.id`(OTel semconv 标准用户属性)随每批导出携带一次。该身份只属于 OTel 回流;dsh-sdk launcher telemetry 保留自己的匿名 id 存储(`telemetry.json`),两者不共享(初版曾做公用 util 包统一两条回流——在有第二个真实消费者之前不抽公共包,回流关联需求出现时再议)。 +`getOrCreateAnonymousUserId()` 返回 `$DSH_HOME/.userid`(`resolveDshHome` 解析,`$DSH_HOME` > `~/.dsh`)中的裸 UUID 行,首用生成随机 UUID v4 并落盘;后端构造时把它作为 Resource 的 `user.id`(OTel semconv 标准用户属性)随每批导出携带一次。原始实现位于 `session-telemetry-otel`,因为当时不存在第二个真实消费方。`/feedback` 后来成为该消费方,因此[共享 id 决策](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md)将所有权移交给 `@deepseek-ai/dsh-user-id`,但不改变本 Note 记录的存储、匿名、并发与丢失语义。dsh-sdk launcher telemetry 继续使用自己独立的匿名 id 存储(`telemetry.json`),与此身份无关。 | 裁定 | 取值 | 理由 | |---|---|---| @@ -22,8 +22,8 @@ session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry | 写失败 | best-effort 返回内存 id | telemetry 永不因 home 只读被阻塞 | | 上报位置 | Resource 属性,非逐条 attributes | 每批一次即够接收端按 Resource 维度聚合;逐条注入要动 seam 约定且涨 wire 体积 | | semconv 依赖 | 不引 `@opentelemetry/semantic-conventions` 包 | 一个字符串常量不值一个依赖 | -| 落点 | `session-telemetry-otel` 包内模块,非公共 util 包 | 仓规「有第二个真实消费者才拆包」;sdk launcher 回流保留自有存储,无现实关联需求 | -| 单独开关 | 无 | 身份跟随 telemetry 整体开关(`DSH_TELEMETRY_DISABLED`);关 telemetry 即整体不报 | +| 落点 | `@deepseek-ai/dsh-user-id`,由 OTel 后端与 `/feedback` 共享 | 第二个真实消费方已经出现;直接反馈不能依赖导出后端 | +| 单独开关 | 无 | 任一消费方都可创建该身份;`DSH_TELEMETRY_DISABLED` 会停止遥测上报,但不会禁用反馈确认 | ## Alternatives considered @@ -31,7 +31,7 @@ session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry |---|---| | hostname/IP 哈希派生 id(此前口径) | 可反查即非匿名;随机 UUID 语义干净,用户裁决取代 | | user.id 放每条 record 的 attributes(Claude Code 形态) | 要动 session-telemetry seam 约定或逐条注入,wire 体积涨;Resource 每批一次已满足聚合 | -| 公用 util 包统一两条回流(初版实现) | 唯一现实消费者是 OTel backend;sdk launcher 换用它只是为统一而统一——用户复议收回,回流关联需求出现时再抽包 | +| 在 `/feedback` 需要该 id 之前抽取共享包(初版实现) | 当时唯一的真实消费方是 OTel 后端;只有直接反馈需要同一个关联 id 后,抽取才具备依据 | | 复用 telemetry.json 不新建文件 | 文件名/JSON 格式把身份挂在 launcher 链路命名下;OTel 回流身份是独立事实 | | AppCLIEntry 读好 id 经 config patch 注入 | 每个 surface 入口都要接线;config 里传运行时事实与部署配置混淆 | | 挂进 `@deepseek-ai/dsh-paths` | paths 是纯路径计算零 IO;带持久化的身份能力会污染包边界 | @@ -39,6 +39,6 @@ session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry ## Consequences - 一个 `$DSH_HOME` 在 OTel 回流中是一个稳定用户;不同 home 在构造上就是不同用户,无跨 home 关联机制。 -- OTel 回流与 launcher 回流各有各的 id(`.userid` 与 `telemetry.json`),无法互相关联——这是「不抽公共包」的直接代价,等真实关联需求出现再统一。 +- OTel 回流与 `/feedback` 共享 `.userid`;launcher 回流仍使用 `telemetry.json`,无法与前两者关联。 - 删除 `.userid` 即重置身份(下次启动生效);home 不可写时每进程各自持有一个内存 id 直至恢复可写。 - [默认挂载 Note](2026-07-31-web-telemetry-default-mount.md) 的身份 follow-up 中「匿名用户 id」项由本决定关闭;hostname/surface 维度与脱敏规则、usage-metrics track 仍是待办。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 80ef25ba39..257da0031a 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -3,12 +3,11 @@ // else covers: sidebar cold listing, the implicit resume/attach inside the // history RPC, history-page tool views, and the client's log-ordered transcript // events — with ZERO model calls in replay (no replay fixture; a stray stream -// fails loud on the open llm seam). The cold session also carries the one -// keyless command-row surfaces: the seeded manual `/compact` lifecycle folds -// into its checkpoint, while an Access-chip pick later runs `/permission` on -// the host. The seed is a recorded -// fixture under the -// same record discipline as every other: DSH_SNAPSHOT=record drives the turn +// fails loud on the open llm seam). The cold session also carries keyless +// command-row surfaces: the seeded manual `/compact` lifecycle folds into its +// checkpoint, an Access-chip pick later runs `/permission` on the host, and +// `/feedback` pins its expandable correlation ids. The seed is a recorded +// fixture under the same record discipline as every other: DSH_SNAPSHOT=record drives the turn // live through the composer (real read tool against seeded workspace files) // and harvests seed.jsonl; replay/refresh seed it cold and only render. import { readFile, writeFile, mkdir } from 'node:fs/promises' @@ -32,9 +31,9 @@ import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url)) const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expected.md', import.meta.url)) -// The command-row golden: the same conversation after one /permission switch, -// which is the only surface that shows a settled command row's copy. +// Command-row goldens over the same conversation after direct host commands. const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/command-row.expected.md', import.meta.url)) +const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/feedback-row.expected.md', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'seeded-history-web-e2e' @@ -446,6 +445,44 @@ describe('web e2e: seeded history renders through cold resume', () => { await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE) }, 60_000) + it.skipIf(MODE === 'record')('reports full feedback correlation ids in an expandable two-line row', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-feedback-row')) + const previousDshHome = process.env.DSH_HOME + process.env.DSH_HOME = scaffold.harnessHome + try { + const input = page.locator('textarea').first() + await input.fill('/feedback the diff view is unreadable') + await input.press('Enter') + const row = page.locator('[data-variant="others"]').filter({ + hasText: `Feedback recorded for session ${SEED_ID}`, + }) + await row.waitFor({ timeout: 10_000 }) + const disclosure = row.locator('[data-expandable]') + expect(await disclosure.getAttribute('aria-expanded')).toBe('false') + await disclosure.click() + await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') + + const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) + if (agent === undefined) throw new Error('seeded session did not attach an agent') + const done = agent.session.events.filter(event => event.type === 'command/done').at(-1) + if (done?.type !== 'command/done') throw new Error('feedback command did not settle') + const [sessionLine, userLine, extraLine] = done.data.text?.split('\n') ?? [] + expect(sessionLine).toBe(`Feedback recorded for session ${SEED_ID}`) + expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + expect(extraLine).toBeUndefined() + const userId = userLine?.slice('User: '.length) + if (userId === undefined) throw new Error('feedback command omitted the user id') + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + .split(userId).join('{{userId}}') + await compareOrRefreshGolden(FEEDBACK_ROW_EXPECTED, snapshot, MODE) + } finally { + if (previousDshHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previousDshHome + } + }, 60_000) + it.skipIf(MODE === 'record')('fits short logged context without a scrollport', async () => { const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) if (agent === undefined) throw new Error('seeded session did not attach an agent') @@ -473,6 +510,6 @@ describe('web e2e: seeded history renders through cold resume', () => { // stream would have failed the turn loudly. Cleanliness pins the wire. expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'seed.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'seed.jsonl', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md new file mode 100644 index 0000000000..87b763d37c --- /dev/null +++ b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md @@ -0,0 +1,53 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the read tool twice" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" +- button "Copy": + - img +- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": + - img + - img + - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. +- button "Read a.txt": + - img + - img + - text: Read + - button "a.txt" +- button "Read b.txt": + - img + - img + - text: Read + - button "b.txt" +- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.": + - img + - img + - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. +- paragraph: DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- button "compact Compacted 5 history items (~{{tokens}} tokens)" +- button "Context injection AGENTS.md": + - img + - img + - text: Context injection AGENTS.md +- img +- text: permission preset read-only +- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" [expanded]': + - img + - text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" +- text: "Feedback recorded for session {{seededId}} User: {{uuid}}" +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Read Only"': Read Only +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index cc76a6b557..7319ee9070 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: a785f856f0c5e3b1f99260e365ba5d15641dd5be -config-catalog.zh.md: 913f7d7771aa3f5e86b199121c64d5b9b00e968d +config-catalog.md: bf5bdc275e4611afaa6950078459ea34723a0d53 +config-catalog.zh.md: 0d9711d729364d2b06dbc7859f7c0a255222979f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a785f856f0..bf5bdc275e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2811,3 +2811,4 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-type-meta` ([`packages/typert/type-meta/src/index.ts`](../packages/typert/type-meta/src/index.ts)) - `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) +- `@deepseek-ai/dsh-user-id` ([`packages/session/user-id/src/index.ts`](../packages/session/user-id/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 913f7d7771..0d9711d729 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2811,3 +2811,4 @@ export interface Config { - `@deepseek-ai/dsh-type-meta`([`packages/typert/type-meta/src/index.ts`](../packages/typert/type-meta/src/index.ts)) - `@deepseek-ai/dsh-typert-generator`([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) - `@deepseek-ai/dsh-typert-registry`([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) +- `@deepseek-ai/dsh-user-id`([`packages/session/user-id/src/index.ts`](../packages/session/user-id/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index c8cd127319..66486e97b7 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: 9df5eabe1c39fd8bb3e14b635e43b782c816d369 -module-graph.zh.md: ee187222fa1ed727941e7820ac8c33ed532497e3 +module-graph.md: a2407f5d394020834172288e3d03518d1e8045db +module-graph.zh.md: 364033c29d773a764ce3f8f0036edeac7c9e0b21 diff --git a/docs/module-graph.md b/docs/module-graph.md index 9df5eabe1c..a2407f5d39 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -269,6 +269,7 @@ flowchart TD pkg_session_title_all_messages_llm["session-title-all-messages-llm"] pkg_session_title_first_message_llm["session-title-first-message-llm"] pkg_session_title_llm["session-title-llm"] + pkg_user_id["user-id"] end subgraph group_settings["packages/settings"] pkg_settings["settings"] @@ -355,6 +356,9 @@ flowchart TD pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths + pkg_user_id --> pkg_brand + pkg_user_id --> pkg_invariants + pkg_user_id --> pkg_paths pkg_settings --> pkg_brand pkg_settings --> pkg_invariants pkg_storage_domain --> pkg_invariants @@ -711,6 +715,7 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_user_id pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -937,13 +942,12 @@ flowchart TD pkg_session_checkpoint_policy --> pkg_session pkg_session_checkpoint_policy --> pkg_session_persistence pkg_session_checkpoint_policy --> pkg_tools - pkg_session_telemetry_otel --> pkg_brand pkg_session_telemetry_otel --> pkg_command_feedback pkg_session_telemetry_otel --> pkg_invariants pkg_session_telemetry_otel --> pkg_llm - pkg_session_telemetry_otel --> pkg_paths pkg_session_telemetry_otel --> pkg_session pkg_session_telemetry_otel --> pkg_session_telemetry + pkg_session_telemetry_otel --> pkg_user_id pkg_session_title_all_messages_llm --> pkg_invariants pkg_session_title_all_messages_llm --> pkg_llm pkg_session_title_all_messages_llm --> pkg_session @@ -1274,6 +1278,7 @@ flowchart TD | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/scaffold/helper) | `scaffold` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/scaffold/telemetry) | `scaffold` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`user-id`](../packages/session/user-id) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -1362,7 +1367,7 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1402,7 +1407,7 @@ flowchart TD | [`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) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | +| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ee187222fa..364033c29d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -271,6 +271,7 @@ flowchart TD pkg_session_title_all_messages_llm["session-title-all-messages-llm"] pkg_session_title_first_message_llm["session-title-first-message-llm"] pkg_session_title_llm["session-title-llm"] + pkg_user_id["user-id"] end subgraph group_settings["packages/settings"] pkg_settings["settings"] @@ -357,6 +358,9 @@ flowchart TD pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths + pkg_user_id --> pkg_brand + pkg_user_id --> pkg_invariants + pkg_user_id --> pkg_paths pkg_settings --> pkg_brand pkg_settings --> pkg_invariants pkg_storage_domain --> pkg_invariants @@ -713,6 +717,7 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_user_id pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -939,13 +944,12 @@ flowchart TD pkg_session_checkpoint_policy --> pkg_session pkg_session_checkpoint_policy --> pkg_session_persistence pkg_session_checkpoint_policy --> pkg_tools - pkg_session_telemetry_otel --> pkg_brand pkg_session_telemetry_otel --> pkg_command_feedback pkg_session_telemetry_otel --> pkg_invariants pkg_session_telemetry_otel --> pkg_llm - pkg_session_telemetry_otel --> pkg_paths pkg_session_telemetry_otel --> pkg_session pkg_session_telemetry_otel --> pkg_session_telemetry + pkg_session_telemetry_otel --> pkg_user_id pkg_session_title_all_messages_llm --> pkg_invariants pkg_session_title_all_messages_llm --> pkg_llm pkg_session_title_all_messages_llm --> pkg_session @@ -1276,6 +1280,7 @@ flowchart TD | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/scaffold/helper) | `scaffold` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/scaffold/telemetry) | `scaffold` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`user-id`](../packages/session/user-id) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -1364,7 +1369,7 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1404,7 +1409,7 @@ flowchart TD | [`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) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | +| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 7ca14e31fb..7a8f73a488 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 9953214182521ac2c1aac8b4589bad7ad45e3094 -persistence-catalog.zh.md: 730513ea259dde274c8c63948dd21fdc0b70417f +persistence-catalog.md: f1dd0f6635bbb2ed2bbf679fdab2664cef08906d +persistence-catalog.zh.md: 7a0f66b5622fbc9527947019da442b21a1b67b9a diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 9953214182..f1dd0f6635 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -364,7 +364,7 @@ Source: [`packages/compact/compact/src/types.ts:33`](../packages/compact/compact 'feedback/record': { text: string } ``` -Source: [`packages/feedback/command-feedback/src/index.ts:24`](../packages/feedback/command-feedback/src/index.ts) +Source: [`packages/feedback/command-feedback/src/index.ts:25`](../packages/feedback/command-feedback/src/index.ts) ### `goal/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 730513ea25..7a0f66b562 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -366,7 +366,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 'feedback/record': { text: string } ``` -来源:[`packages/feedback/command-feedback/src/index.ts:24`](../packages/feedback/command-feedback/src/index.ts) +来源:[`packages/feedback/command-feedback/src/index.ts:25`](../packages/feedback/command-feedback/src/index.ts) ### `goal/*` diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index b1b1a8d4d4..ea0c591ae2 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/feedback/command-feedback/README.md -README.md: 96d2825f4b63c95ad6f45ca8b2e05d1fc5ae92aa -README.zh.md: 5220afe68b1f0de50fd1368900758906ee6907c9 +README.md: 52b8fb6a423fca69f76397deec36ecd22a6a6023 +README.zh.md: ca74d53f2531a46c2c16aa1423cee52e89c8256f diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index 96d2825f4b..52b8fb6a42 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -8,7 +8,7 @@ Trigger-independent session feedback plus human-facing `/feedback` capture. The | Input | Result | |---|---| -| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded for session {id}`. | +| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}` followed by `User: {userId}`. | | `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. | Surrounding whitespace is discarded, but feedback is otherwise unparsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own event; nothing is replaced or merged. @@ -17,7 +17,7 @@ Surrounding whitespace is discarded, but feedback is otherwise unparsed: no trun `recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer and starts no model work. The optional [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) consumer observes the event without changing its capture contract. -The feedback text appears in exactly one durable payload: `feedback/record`. [`dsh-commands`](../../interaction/commands/README.md) still appends its generic `command/run` / `command/done` pairing, but this definition sets `recordInput: false`, so `command/run` omits `args`; the paired `command/done` carries only the outcome. All three events are log-only and absent from the ordered surface, `deriveMessages()`, and model requests. These appends start persistence's ordinary eager drain, but neither producer forces `session/flush`, so acknowledgement means the feedback is in the log, not that it has reached disk. Rejected empty input leaves only the command pairing settled as `kind: 'error'`, with no `feedback/record`. +The feedback text appears in exactly one durable payload: `feedback/record`. [`dsh-commands`](../../interaction/commands/README.md) still appends its generic `command/run` / `command/done` pairing, but this definition sets `recordInput: false`, so `command/run` omits `args`; the paired `command/done` carries only the outcome. All three events are log-only and absent from the ordered surface, `deriveMessages()`, and model requests. These appends start persistence's ordinary eager drain, but neither producer forces `session/flush`, so acknowledgement means the feedback is in the log, not that it has reached disk. The acknowledgement identifies both the receiving session and the [shared anonymous user](../../session/user-id/); the first accepted feedback for a harness home can create `$DSH_HOME/.userid`. Rejected empty input leaves only the command pairing settled as `kind: 'error'`, with no `feedback/record` and no user-id lookup. The event is authoritative rather than the command record because feedback may arrive through a trigger other than `/feedback`. Keeping the payload out of `command/run` avoids two records carrying the same text. diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md index 5220afe68b..ca74d53f25 100644 --- a/packages/feedback/command-feedback/README.zh.md +++ b/packages/feedback/command-feedback/README.zh.md @@ -8,7 +8,7 @@ | 输入 | 结果 | |---|---| -| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded for session {id}` 确认。 | +| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}` 确认,随后显示 `User: {userId}`。 | | `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 | 前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。 @@ -17,7 +17,7 @@ `recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,且不启动任何模型工作。可选的 [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) 消费方会观察该事件,但不改变它的采集约定。 -反馈文本只出现在一个持久载荷中:`feedback/record`。[`dsh-commands`](../../interaction/commands/README.md) 仍会追加通用的 `command/run` / `command/done` 配对,但此定义设置了 `recordInput: false`,因此 `command/run` 会省略 `args`;配对的 `command/done` 只携带结果。三个事件都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及模型请求中。这些追加会启动持久化的常规即时排空,但两个生产方都不会强制 `session/flush`,因此确认文本表示反馈已进入日志,而不表示它已经落盘。被拒绝的空输入只会留下以 `kind: 'error'` 结算的命令配对,不会产生 `feedback/record`。 +反馈文本只出现在一个持久载荷中:`feedback/record`。[`dsh-commands`](../../interaction/commands/README.md) 仍会追加通用的 `command/run` / `command/done` 配对,但此定义设置了 `recordInput: false`,因此 `command/run` 会省略 `args`;配对的 `command/done` 只携带结果。三个事件都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及模型请求中。这些追加会启动持久化的常规即时排空,但两个生产方都不会强制 `session/flush`,因此确认文本表示反馈已进入日志,而不表示它已经落盘。确认文本同时标明接收反馈的会话和[共享匿名用户](../../session/user-id/);对于某个 harness home,首次接受反馈时可能创建 `$DSH_HOME/.userid`。被拒绝的空输入只会留下以 `kind: 'error'` 结算的命令配对,不会产生 `feedback/record`,也不会查找用户 id。 权威记录是该事件,而不是命令记录,因为反馈可能来自 `/feedback` 之外的触发方式。让载荷不进入 `command/run`,可避免两条记录携带相同文本。 diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 535c438a63..433087eff3 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-user-id": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -38,6 +39,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-user-id": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index 92ef839415..7f0bb3a59f 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -9,6 +9,7 @@ import type { Context } from 'cordis' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' import type { Session } from '@deepseek-ai/dsh-session' +import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' export const name = 'command-feedback' export const inject = ['commands'] @@ -41,8 +42,8 @@ export function recordFeedback(session: Session, text: string): void { * Validate, record, and acknowledge one feedback entry. Returning an error * leaves no `feedback/record` event. * @param invocation - receiving agent, raw command input, and UI cancellation. - * @returns an acknowledgement containing the receiving session id, or a usage error - * when no feedback text was supplied. + * @returns an acknowledgement containing the receiving session and anonymous + * user ids, or a usage error when no feedback text was supplied. */ function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { if (invocation.rawInput.trim().length === 0) { @@ -51,7 +52,7 @@ function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { recordFeedback(invocation.agent.session, invocation.rawInput) return { kind: 'success', - text: `Feedback recorded for session ${invocation.agent.session.id}`, + text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}`, } } diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 19d886af00..6f93ff854e 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' @@ -7,6 +7,17 @@ import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session' import * as commandFeedback from '@deepseek-ai/dsh-command-feedback' +const { USER_ID, getOrCreateAnonymousUserId } = vi.hoisted(() => { + const USER_ID = '01234567-89ab-4cde-8f01-23456789abcd' + return { USER_ID, getOrCreateAnonymousUserId: vi.fn(() => USER_ID) } +}) + +vi.mock('@deepseek-ai/dsh-user-id', () => ({ + getOrCreateAnonymousUserId, +})) + +beforeEach(() => getOrCreateAnonymousUserId.mockClear()) + interface Harness { readonly ctx: Context readonly agent: Agent @@ -93,7 +104,7 @@ describe('/feedback human command', () => { const test = await harness() await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({ kind: 'success', - text: `Feedback recorded for session ${test.session.id}`, + text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}`, }) expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable']) const commandRun = test.session.events.find(event => event.type === 'command/run') @@ -141,8 +152,8 @@ describe('/feedback human command', () => { test.ctx.commands.execute(test.agent, '/feedback second', signal), ]) expect(settled.map(item => item?.result)).toEqual([ - { kind: 'success', text: `Feedback recorded for session ${test.session.id}` }, - { kind: 'success', text: `Feedback recorded for session ${test.session.id}` }, + { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` }, + { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` }, ]) expect(feedbackTexts(test.session)).toEqual(['first', 'second']) }) @@ -167,6 +178,7 @@ describe('/feedback human command', () => { } await expect(run(test)).resolves.toEqual(expected) await expect(run(test, ' \n\t ')).resolves.toEqual(expected) + expect(getOrCreateAnonymousUserId).not.toHaveBeenCalled() expect(feedbackTexts(test.session)).toEqual([]) const done = test.session.events.filter(event => event.type === 'command/done') expect(done.map(event => event.data.kind)).toEqual(['error', 'error']) diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 98609afdea..958b23736f 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' @@ -11,6 +11,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import * as CommandFeedback from '@deepseek-ai/dsh-command-feedback' +import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' let root: string | undefined let context: Context | undefined @@ -20,6 +21,7 @@ afterEach(async () => { context = undefined if (root !== undefined) await rm(root, { recursive: true, force: true }) root = undefined + vi.unstubAllEnvs() }) /** Register one idle agent over a store-owned session, as an app's spine does. */ @@ -51,6 +53,7 @@ function agent(ctx: Context): Agent { describe('/feedback real Loader composition through cordis.yml', () => { it('boots cordis.yml and records feedback without model-visible output', async () => { root = await mkdtemp(join(tmpdir(), 'dsh-command-feedback-loader-')) + vi.stubEnv('DSH_HOME', root) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-agent'", @@ -87,9 +90,10 @@ describe('/feedback real Loader composition through cordis.yml', () => { expect(context.commands.list(owner).map(command => command.name)).toContain('feedback') const accepted = await context.commands.execute(owner, '/feedback the diff view is unreadable', signal) + const userId = getOrCreateAnonymousUserId({ env: { DSH_HOME: root } }) expect(accepted?.result).toEqual({ kind: 'success', - text: 'Feedback recorded for session feedback-loader-agent', + text: `Feedback recorded for session feedback-loader-agent\nUser: ${userId}`, }) const rejected = await context.commands.execute(owner, '/feedback', signal) expect(rejected?.result).toEqual({ diff --git a/packages/feedback/command-feedback/tsconfig.json b/packages/feedback/command-feedback/tsconfig.json index f59431af9a..c39f55f60f 100644 --- a/packages/feedback/command-feedback/tsconfig.json +++ b/packages/feedback/command-feedback/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/session" }, + { + "path": "../../session/user-id" + }, { "path": "../../support/invariants" } diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 5d941e3fe1..2af0b5294e 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -35,23 +35,21 @@ }, "peerDependencies": { "@deepseek-ai/dsh-command-feedback": "^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-paths": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-telemetry": "^0.0.1", + "@deepseek-ai/dsh-user-id": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-command-feedback": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-telemetry": "workspace:^", + "@deepseek-ai/dsh-user-id": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index b128f65978..b66d641750 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -25,7 +25,7 @@ import { type TelemetrySeverity, } from '@deepseek-ai/dsh-session-telemetry' import { APP_IDENTITY } from '@deepseek-ai/dsh-llm' -import { getOrCreateAnonymousUserId } from './user-id.ts' +import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' import { BatchLogRecordProcessor, LoggerProvider, diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index 6139b5c505..511c95c0d8 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -13,7 +13,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { gunzipSync } from 'node:zlib' import { Context } from 'cordis' -import { getOrCreateAnonymousUserId } from '../src/user-id.ts' +import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' import Loader from '@cordisjs/plugin-loader' import { recordFeedback } from '@deepseek-ai/dsh-command-feedback' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-telemetry-otel/tsconfig.json b/packages/session/session-telemetry-otel/tsconfig.json index 60aee08eda..421742a62d 100644 --- a/packages/session/session-telemetry-otel/tsconfig.json +++ b/packages/session/session-telemetry-otel/tsconfig.json @@ -30,10 +30,7 @@ "path": "../session-telemetry" }, { - "path": "../../util/brand" - }, - { - "path": "../../util/paths" + "path": "../user-id" }, { "path": "../../support/invariants" diff --git a/packages/session/user-id/README.i18n.yaml b/packages/session/user-id/README.i18n.yaml new file mode 100644 index 0000000000..5d58bba70e --- /dev/null +++ b/packages/session/user-id/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session/user-id/README.md +README.md: 31a72f5e7b58b90b165b16374c2301389cbe2ca0 +README.zh.md: 013097b3038c43ff740660ef9159ca2b13f7b743 diff --git a/packages/session/user-id/README.md b/packages/session/user-id/README.md new file mode 100644 index 0000000000..31a72f5e7b --- /dev/null +++ b/packages/session/user-id/README.md @@ -0,0 +1,29 @@ +# @deepseek-ai/dsh-user-id + +English | [中文](README.zh.md) + +Shared anonymous identity for session telemetry and direct feedback acknowledgement. `getOrCreateAnonymousUserId()` returns a random UUID v4 scoped to one harness home, persisted as the bare line `$DSH_HOME/.userid` (`~/.dsh/.userid` when `DSH_HOME` is unset). The OpenTelemetry backend reports it as Resource `user.id`; `/feedback` includes the same value in its acknowledgement so an operator can correlate a submitted session and user with exported telemetry. + +The identity is never derived from the hostname, network address, git remote, or another identifying source. Deleting `.userid` resets the identity on the next process launch. Separate harness homes have separate identities, and the dsh-sdk launcher telemetry intentionally keeps its own unrelated store. + +## Storage contract + +Reads and writes are synchronous because both boot-time telemetry construction and direct command execution need one API. The result is memoized per resolved file path for the process lifetime. A first writer uses exclusive creation and a concurrent loser adopts the persisted winner; a corrupt file is replaced. Persistence is best-effort, so an unwritable home still receives a process-local UUID rather than blocking telemetry or feedback. + +## Composition + +This package is a shared library, not a Cordis plugin. Consumers import `getOrCreateAnonymousUserId()` directly. Its invariant companion is intentionally empty because the package owns no event stream or public mutable relation that can be checked without creating the identity as a side effect. + +## Model Experience + +None, as the identifier is used only in telemetry metadata and a direct human command response; it never enters a model request. + +#### KV Cache effect + +None; this package never contributes to a model request. + +## Known Limitations and Deferred Work + +- **No recovery after deletion** — loss mints a new anonymous identity by design; recovery would require stable derivation material that weakens anonymity. +- **Best-effort concurrency** — a reader landing in the narrow interval between a concurrent process's exclusive create and completed write can use a different in-memory UUID for that run; later launches converge on the persisted value. +- **No cross-home identity** — different `$DSH_HOME` values cannot be correlated, and this package does not unify the separate dsh-sdk launcher telemetry identity. diff --git a/packages/session/user-id/README.zh.md b/packages/session/user-id/README.zh.md new file mode 100644 index 0000000000..013097b303 --- /dev/null +++ b/packages/session/user-id/README.zh.md @@ -0,0 +1,29 @@ +# @deepseek-ai/dsh-user-id + +[English](README.md) | 中文 + +会话遥测与直接反馈确认共用的匿名身份。`getOrCreateAnonymousUserId()` 返回一个限定于单个 harness home 的随机 UUID v4,并以裸行形式持久化到 `$DSH_HOME/.userid`(未设置 `DSH_HOME` 时为 `~/.dsh/.userid`)。OpenTelemetry 后端将其作为 Resource 的 `user.id` 上报;`/feedback` 在确认文本中包含同一个值,以便运维人员将所报告的会话和用户与导出的遥测相关联。 + +该身份绝不从 hostname、网络地址、git remote 或其他可用于识别身份的来源派生。删除 `.userid` 后,下次启动进程时会重置身份。不同 harness home 拥有不同身份,dsh-sdk launcher telemetry 则刻意使用与此无关的独立存储。 + +## 存储契约 + +读写采用同步方式,因为启动时构造遥测和直接执行命令都需要使用同一个 API。结果在进程生命周期内按解析后的文件路径缓存。首个写入方采用独占创建;并发竞争中失败的一方会采用已持久化的胜出值。损坏的文件会被替换。持久化采用 best-effort,因此即使 home 不可写,系统仍会返回进程本地 UUID,而不会阻塞遥测或反馈。 + +## 组合 + +本包是共享库,并非 Cordis 插件。消费方直接导入 `getOrCreateAnonymousUserId()`。其不变式伴生插件刻意留空,因为本包既不拥有事件流,也不拥有任何可以在不触发创建身份这一副作用的情况下检查的公开可变关系。 + +## 模型体验 + +无,因为该标识符只用于遥测元数据和面向用户的直接命令响应;它绝不会进入模型请求。 + +#### KV Cache 影响 + +无;本包绝不会向模型请求贡献任何内容。 + +## 已知限制与暂缓工作 + +- **删除后无法恢复**:身份丢失后会按设计生成新的匿名身份;若要恢复身份,就需要稳定的派生材料,这会削弱匿名性。 +- **Best-effort 并发**:如果读取方恰好落在并发进程完成独占创建但尚未写完的狭窄时间窗内,本次运行可能使用不同的内存 UUID;后续启动会收敛到已持久化的值。 +- **没有跨 home 身份**:不同 `$DSH_HOME` 值之间无法关联,本包也不会统一 dsh-sdk launcher telemetry 的独立身份。 diff --git a/packages/session/user-id/package.json b/packages/session/user-id/package.json new file mode 100644 index 0000000000..2a09c73b0e --- /dev/null +++ b/packages/session/user-id/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-user-id", + "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/session/session-telemetry-otel/src/user-id.ts b/packages/session/user-id/src/index.ts similarity index 79% rename from packages/session/session-telemetry-otel/src/user-id.ts rename to packages/session/user-id/src/index.ts index 0a2cf95a6a..ca314e945a 100644 --- a/packages/session/session-telemetry-otel/src/user-id.ts +++ b/packages/session/user-id/src/index.ts @@ -1,22 +1,20 @@ /** - * Per-harness-home anonymous user id for the OTel Resource. + * Per-harness-home anonymous user id shared by telemetry and feedback. * * The id is a random UUID persisted as a bare line in `.userid` inside the * harness home resolved by {@link resolveDshHome} (`$DSH_HOME` > `~/.dsh`), * and never derived from the hostname, network address, git remote, or any - * other identifying source — a derived id would make "anonymous" a fiction. - * The id is scoped to the harness home, not the machine: every process - * sharing one `$DSH_HOME` reports the same id, and deleting the file simply - * mints a fresh identity on the next launch (loss is accepted by design). - * This identity belongs to the OTel feed alone; the dsh-sdk launcher - * telemetry keeps its own separate store. + * other identifying source. It is scoped to the harness home, not the + * machine: every process sharing one `$DSH_HOME` reports the same id, and + * deleting the file mints a fresh identity on the next launch. The dsh-sdk + * launcher telemetry keeps its own separate store. * - * Reads and writes are synchronous so the backend constructor can call this - * on its boot path, and the result is memoized per resolved file path: one - * process touches the disk once, and a file deleted mid-run keeps the - * process's id until the next launch. + * Reads and writes are synchronous so boot-time and command consumers can + * use one API. The result is memoized per resolved file path: one process + * touches the disk once, and a file deleted mid-run keeps the process's id + * until the next launch. * - * @module @deepseek-ai/dsh-session-telemetry-otel/user-id + * @module @deepseek-ai/dsh-user-id */ import { randomUUID } from 'node:crypto' @@ -64,8 +62,8 @@ function readPersistedId(file: string): AnonymousUserId | undefined { * narrow create-to-write window can still yield two per-process ids for that * run; the next launch converges on the persisted one.) Persistence is * best-effort — a write failure (read-only home) still returns a usable id - * for the current run so telemetry is never blocked. - * @param options - Home-location and UUID-generation hooks. + * for the current run so feedback and telemetry are never blocked. + * @param options - home-location and UUID-generation seams. * @returns the stable per-harness-home anonymous user id. */ export function getOrCreateAnonymousUserId(options: AnonymousUserIdOptions = {}): AnonymousUserId { diff --git a/packages/session/user-id/src/invariant.ts b/packages/session/user-id/src/invariant.ts new file mode 100644 index 0000000000..b649e23619 --- /dev/null +++ b/packages/session/user-id/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-user-id`. + * @module @deepseek-ai/dsh-user-id/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-user-id' + +/** Cordis companion plugin name. */ +export const name = 'user-id-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the API owns one private memo and one best-effort + * file, with no independent event stream or public mutable relation for a + * companion to compare without creating the identity as a side effect. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session/user-id/tests/invariant.spec.ts b/packages/session/user-id/tests/invariant.spec.ts new file mode 100644 index 0000000000..abffc89621 --- /dev/null +++ b/packages/session/user-id/tests/invariant.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as UserIdInvariant from '@deepseek-ai/dsh-user-id/invariant' + +describe('invariant companion', () => { + it('registers the package ownership with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(UserIdInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/session/session-telemetry-otel/tests/user-id.spec.ts b/packages/session/user-id/tests/user-id.spec.ts similarity index 99% rename from packages/session/session-telemetry-otel/tests/user-id.spec.ts rename to packages/session/user-id/tests/user-id.spec.ts index f7abf45f0b..0f21cb8204 100644 --- a/packages/session/session-telemetry-otel/tests/user-id.spec.ts +++ b/packages/session/user-id/tests/user-id.spec.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { USER_ID_FILE_NAME, getOrCreateAnonymousUserId, -} from '../src/user-id.ts' +} from '../src/index.ts' const dirs: string[] = [] diff --git a/packages/session/user-id/tsconfig.json b/packages/session/user-id/tsconfig.json new file mode 100644 index 0000000000..52e417d5ba --- /dev/null +++ b/packages/session/user-id/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../util/brand" + }, + { + "path": "../../util/paths" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7de565e0c9..2507f83973 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3686,6 +3686,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-user-id': + specifier: workspace:^ + version: link:../../session/user-id cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -5845,9 +5848,6 @@ importers: '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand '@deepseek-ai/dsh-command-feedback': specifier: workspace:^ version: link:../../feedback/command-feedback @@ -5857,15 +5857,15 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-paths': - specifier: workspace:^ - version: link:../../util/paths '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session '@deepseek-ai/dsh-session-telemetry': specifier: workspace:^ version: link:../session-telemetry + '@deepseek-ai/dsh-user-id': + specifier: workspace:^ + version: link:../user-id cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -5988,6 +5988,21 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/session/user-id: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/settings/settings: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 1b39447215..1a1de87d09 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -127,6 +127,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = { 'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers no model surface.' }, 'packages/session/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/session/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, + 'packages/session/user-id': { kind: 'none', reason: 'The shared identifier appears only in telemetry metadata and a direct human command response; it registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-badge': { kind: 'indirect', reason: 'The bundled provider delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 750b586f5f..d9bf1c29e4 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -139,6 +139,7 @@ { "path": "./packages/session/session-title-first-message-llm" }, { "path": "./packages/session/session-title-all-messages-llm" }, { "path": "./packages/session/session-telemetry" }, + { "path": "./packages/session/user-id" }, { "path": "./packages/session/session-telemetry-otel" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, From ccbaedc8a888ddbb3233e7925432e0cdb3c93b77 Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Mon, 10 Aug 2026 16:29:55 +0800 Subject: [PATCH 090/229] 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 a27efdef363de392d675d4ce517db58b78826b44 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Sun, 9 Aug 2026 15:27:21 +0800 Subject: [PATCH 091/229] docs: make technical prose concrete --- .agents/notes/AGENTS.md | 2 +- .agents/notes/README.i18n.yaml | 4 +- .agents/notes/README.md | 12 +-- .agents/notes/README.zh.md | 8 +- .agents/notes/implemented/AGENTS.md | 2 +- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 4 +- ...026-07-19-gui-layering-and-rpc-protocol.md | 40 +++++----- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 36 ++++----- ...4-configuration-source-ownership.i18n.yaml | 4 +- ...26-08-04-configuration-source-ownership.md | 2 +- ...08-04-configuration-source-ownership.zh.md | 2 +- .../feature/2026-06-15-code-mode.i18n.yaml | 4 +- .../feature/2026-06-15-code-mode.md | 6 +- .../feature/2026-06-15-code-mode.zh.md | 6 +- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 4 +- .../2026-06-21-subagent-capability-seam.zh.md | 4 +- .../feature/2026-06-30-hook-bridges.i18n.yaml | 4 +- .../feature/2026-06-30-hook-bridges.md | 2 +- .../feature/2026-06-30-hook-bridges.zh.md | 2 +- ...16-durable-per-step-time-context.i18n.yaml | 4 +- ...026-07-16-durable-per-step-time-context.md | 2 +- ...-07-16-durable-per-step-time-context.zh.md | 2 +- ...26-07-26-code-dispatch-log-spill.i18n.yaml | 4 +- .../2026-07-26-code-dispatch-log-spill.md | 20 ++--- .../2026-07-26-code-dispatch-log-spill.zh.md | 20 ++--- ...-plan-review-presentation-intent.i18n.yaml | 4 +- ...6-07-30-plan-review-presentation-intent.md | 6 +- ...7-30-plan-review-presentation-intent.zh.md | 6 +- ...3-translation-prompt-v4-contract.i18n.yaml | 4 +- ...26-07-23-translation-prompt-v4-contract.md | 6 +- ...07-23-translation-prompt-v4-contract.zh.md | 6 +- ...staller-adopts-existing-checkout.i18n.yaml | 4 +- ...7-31-installer-adopts-existing-checkout.md | 10 +-- ...1-installer-adopts-existing-checkout.zh.md | 10 +-- ...08-unified-github-label-taxonomy.i18n.yaml | 4 +- ...026-08-08-unified-github-label-taxonomy.md | 10 +-- ...-08-08-unified-github-label-taxonomy.zh.md | 10 +-- ...-09-chinese-contract-terminology.i18n.yaml | 4 +- ...2026-08-09-chinese-contract-terminology.md | 2 +- ...6-08-09-chinese-contract-terminology.zh.md | 2 +- ...-09-committed-artifact-citations.i18n.yaml | 4 +- ...2026-08-09-committed-artifact-citations.md | 4 +- ...6-08-09-committed-artifact-citations.zh.md | 4 +- ...-names-actors-and-recorded-facts.i18n.yaml | 4 +- ...e-prose-names-actors-and-recorded-facts.md | 4 +- ...rose-names-actors-and-recorded-facts.zh.md | 4 +- ...7-29-shared-base-config-overlays.i18n.yaml | 4 +- .../2026-07-29-shared-base-config-overlays.md | 2 +- ...26-07-29-shared-base-config-overlays.zh.md | 2 +- ...root-and-derived-medium-recovery.i18n.yaml | 4 +- ...torage-root-and-derived-medium-recovery.md | 2 +- ...age-root-and-derived-medium-recovery.zh.md | 2 +- .../skills/dsh-archive-agent-notes/SKILL.md | 12 +-- .agents/skills/dsh-code-review/SKILL.md | 20 ++--- .agents/skills/dsh-doc-site-sync/SKILL.md | 2 +- .agents/skills/dsh-doc-standards/SKILL.md | 12 +-- .../skills/dsh-find-simplifications/SKILL.md | 26 +++--- .agents/skills/dsh-prose-standard/SKILL.md | 14 ++-- .../dsh-prose-standard/references/examples.md | 16 ++-- .agents/skills/dsh-translate-docs/SKILL.md | 2 +- .agents/skills/dsh-trim-cot-leakage/SKILL.md | 4 +- .../references/examples.md | 2 +- .../references/recall-batteries.md | 6 +- .agents/skills/record-browser-gif/SKILL.md | 8 +- .github/issue-management/policy.mjs | 4 +- AGENTS.md | 12 +-- README.i18n.yaml | 4 +- README.md | 2 +- README.zh.md | 2 +- .../agent-presets/code/agent.cordis.yml | 2 +- .../agent-presets/cordis/agent.cordis.yml | 2 +- .../editing-cordis-compositions/SKILL.md | 4 +- .../agent-presets/standard/agent.cordis.yml | 2 +- apps/cli/reference/README.i18n.yaml | 2 +- apps/cli/reference/README.md | 2 +- apps/cli/src/args.ts | 2 +- apps/cli/src/process-shutdown.ts | 4 +- apps/cli/tests/memory-mcp-configs.spec.ts | 4 +- apps/cli/tests/source-launch.compat.spec.ts | 2 +- apps/web/tests/approval-composer.e2e.ts | 6 +- apps/web/tests/chat-long-interactions.e2e.ts | 2 +- apps/web/tests/complex-history.perf.ts | 4 +- apps/web/tests/composer-draft-scroll.e2e.ts | 10 +-- .../tests/details-session-lifecycle.e2e.ts | 2 +- apps/web/tests/navigation-panes.e2e.ts | 4 +- apps/web/tests/pwsh-terminal.e2e.ts | 6 +- apps/web/tests/question-composer.e2e.ts | 4 +- apps/web/tests/scaffold.ts | 6 +- apps/web/tests/search-card.snapshot.ts | 8 +- apps/web/tests/seeded-history.e2e.ts | 4 +- apps/web/tests/shipped-composition.e2e.ts | 2 +- apps/web/tests/startup-auto-selection.e2e.ts | 9 +-- apps/web/tests/todo-row.snapshot.ts | 2 +- apps/web/tests/turn-tail-actions.e2e.ts | 4 +- apps/web/tests/workspace-management.e2e.ts | 4 +- apps/web/vite.config.ts | 6 +- docs/AGENTS.md | 18 ++--- docs/agent-lifecycle.i18n.yaml | 4 +- docs/agent-lifecycle.md | 4 +- docs/agent-lifecycle.zh.md | 4 +- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 6 +- docs/api-gateway.zh.md | 6 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 6 +- docs/architecture.zh.md | 8 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 2 +- docs/capability-seams.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 20 ++--- docs/config-catalog.zh.md | 20 ++--- docs/cookbook/adding-a-package.i18n.yaml | 4 +- docs/cookbook/adding-a-package.md | 6 +- docs/cookbook/adding-a-package.zh.md | 6 +- docs/cookbook/adding-a-tool.i18n.yaml | 4 +- docs/cookbook/adding-a-tool.md | 4 +- docs/cookbook/adding-a-tool.zh.md | 4 +- .../adding-a-vendored-package.i18n.yaml | 4 +- docs/cookbook/adding-a-vendored-package.md | 6 +- docs/cookbook/adding-a-vendored-package.zh.md | 6 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 6 +- docs/cookbook/extension-cookbook.zh.md | 6 +- .../maintaining-dsh-code-review.i18n.yaml | 4 +- docs/cookbook/maintaining-dsh-code-review.md | 2 +- .../maintaining-dsh-code-review.zh.md | 2 +- docs/cordis-primer.i18n.yaml | 2 +- docs/cordis-primer.md | 2 +- .../cordis-tutorial/01-first-plugin.i18n.yaml | 4 +- docs/cordis-tutorial/01-first-plugin.md | 2 +- docs/cordis-tutorial/01-first-plugin.zh.md | 2 +- docs/cordis-tutorial/index.i18n.yaml | 4 +- docs/cordis-tutorial/index.md | 2 +- docs/cordis-tutorial/index.zh.md | 2 +- docs/defensive-patterns.i18n.yaml | 4 +- docs/defensive-patterns.md | 4 +- docs/defensive-patterns.zh.md | 4 +- docs/development.i18n.yaml | 4 +- docs/development.md | 14 ++-- docs/development.zh.md | 14 ++-- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 6 +- docs/event-producer-consumer.zh.md | 6 +- docs/graph-atlas.i18n.yaml | 4 +- docs/graph-atlas.md | 2 +- docs/graph-atlas.zh.md | 2 +- docs/i18n/README.i18n.yaml | 4 +- docs/i18n/README.md | 6 +- docs/i18n/README.zh.md | 6 +- docs/i18n/style-samples.md | 14 ++-- docs/i18n/translation-prompt.md | 16 ++-- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- ...-acp-default-export-drops-inject.i18n.yaml | 4 +- .../0001-acp-default-export-drops-inject.md | 4 +- ...0001-acp-default-export-drops-inject.zh.md | 4 +- ...ession-disabled-filesystem-tools.i18n.yaml | 4 +- ...js-expression-disabled-filesystem-tools.md | 4 +- ...expression-disabled-filesystem-tools.zh.md | 4 +- ...0003-web-agent-gui-feedback-loop.i18n.yaml | 4 +- .../0003-web-agent-gui-feedback-loop.md | 2 +- .../0003-web-agent-gui-feedback-loop.zh.md | 2 +- docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/README.md | 10 +-- docs/subsystems/README.zh.md | 10 +-- docs/subsystems/bash.i18n.yaml | 4 +- docs/subsystems/bash.md | 2 +- docs/subsystems/bash.zh.md | 2 +- docs/subsystems/code-runtime.i18n.yaml | 2 +- docs/subsystems/code-runtime.md | 2 +- docs/subsystems/compaction.i18n.yaml | 4 +- docs/subsystems/compaction.md | 4 +- docs/subsystems/compaction.zh.md | 4 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 20 ++--- docs/subsystems/core.zh.md | 22 +++--- docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 2 +- docs/subsystems/credentials.zh.md | 2 +- docs/subsystems/filesystem.i18n.yaml | 4 +- docs/subsystems/filesystem.md | 16 ++-- docs/subsystems/filesystem.zh.md | 16 ++-- docs/subsystems/goal.i18n.yaml | 4 +- docs/subsystems/goal.md | 4 +- docs/subsystems/goal.zh.md | 4 +- docs/subsystems/http-server.i18n.yaml | 4 +- docs/subsystems/http-server.md | 8 +- docs/subsystems/http-server.zh.md | 8 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 30 +++---- docs/subsystems/llm-streaming.zh.md | 30 +++---- docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 2 +- docs/subsystems/persistence.zh.md | 2 +- docs/subsystems/plan.i18n.yaml | 4 +- docs/subsystems/plan.md | 43 +++++----- docs/subsystems/plan.zh.md | 43 +++++----- docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 6 +- docs/subsystems/session-projection.zh.md | 6 +- docs/subsystems/session-query.i18n.yaml | 4 +- docs/subsystems/session-query.md | 4 +- docs/subsystems/session-query.zh.md | 4 +- docs/subsystems/session-reference.i18n.yaml | 4 +- docs/subsystems/session-reference.md | 2 +- docs/subsystems/session-reference.zh.md | 2 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 6 +- docs/subsystems/session.zh.md | 8 +- docs/subsystems/settings.i18n.yaml | 4 +- docs/subsystems/settings.md | 6 +- docs/subsystems/settings.zh.md | 6 +- docs/subsystems/storage.i18n.yaml | 4 +- docs/subsystems/storage.md | 6 +- docs/subsystems/storage.zh.md | 6 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 9 ++- docs/subsystems/subagent.zh.md | 9 ++- docs/subsystems/system-prompt.i18n.yaml | 4 +- docs/subsystems/system-prompt.md | 2 +- docs/subsystems/system-prompt.zh.md | 2 +- docs/subsystems/tasks.i18n.yaml | 4 +- docs/subsystems/tasks.md | 6 +- docs/subsystems/tasks.zh.md | 6 +- docs/subsystems/telemetry.i18n.yaml | 4 +- docs/subsystems/telemetry.md | 17 ++-- docs/subsystems/telemetry.zh.md | 17 ++-- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 27 ++++--- docs/subsystems/tools.zh.md | 27 ++++--- docs/subsystems/user-interaction.i18n.yaml | 4 +- docs/subsystems/user-interaction.md | 10 +-- docs/subsystems/user-interaction.zh.md | 10 +-- docs/subsystems/web.i18n.yaml | 4 +- docs/subsystems/web.md | 4 +- docs/subsystems/web.zh.md | 4 +- docs/subsystems/workflow.i18n.yaml | 4 +- docs/subsystems/workflow.md | 10 +-- docs/subsystems/workflow.zh.md | 10 +-- docs/testing.i18n.yaml | 4 +- docs/testing.md | 8 +- docs/testing.zh.md | 8 +- docs/tool-execution-pipeline.i18n.yaml | 4 +- docs/tool-execution-pipeline.md | 2 +- docs/tool-execution-pipeline.zh.md | 2 +- docs/user/develop/basic/index.i18n.yaml | 4 +- docs/user/develop/basic/index.md | 2 +- docs/user/develop/basic/index.zh.md | 2 +- docs/user/develop/basic/publish.i18n.yaml | 4 +- docs/user/develop/basic/publish.md | 4 +- docs/user/develop/basic/publish.zh.md | 4 +- docs/user/develop/practice/index.i18n.yaml | 4 +- docs/user/develop/practice/index.md | 6 +- docs/user/develop/practice/index.zh.md | 8 +- docs/web-styling.i18n.yaml | 2 +- docs/web-styling.zh.md | 2 +- examples/AGENTS.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 2 +- examples/acp-agent/tests/escalation.e2e.ts | 8 +- .../headless-agent/tests/keyless-smoke.e2e.ts | 2 +- examples/mcp-memory/README.i18n.yaml | 4 +- examples/mcp-memory/README.md | 4 +- examples/mcp-memory/README.zh.md | 4 +- examples/web-cordis/README.i18n.yaml | 4 +- examples/web-cordis/README.md | 2 +- examples/web-cordis/README.zh.md | 2 +- native/landlock-run/AGENTS.md | 6 +- native/landlock-run/docs/architecture.md | 2 +- native/landlock-run/docs/packaging.md | 2 +- packages/AGENTS.md | 12 +-- packages/bash/bash-sandbox/src/index.ts | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 6 +- packages/boot/app-boot/README.zh.md | 6 +- packages/boot/app-boot/src/index.ts | 8 +- packages/boot/app-boot/src/profile.ts | 2 +- packages/client/AGENTS.md | 18 ++--- packages/client/hmr/README.i18n.yaml | 2 +- packages/client/hmr/README.md | 2 +- packages/client/modules/README.i18n.yaml | 4 +- packages/client/modules/README.md | 2 +- packages/client/modules/README.zh.md | 2 +- packages/client/modules/src/index.ts | 4 +- packages/client/tsdown.client.ts | 8 +- packages/client/ui-command/README.i18n.yaml | 4 +- packages/client/ui-command/README.md | 6 +- packages/client/ui-command/README.zh.md | 6 +- .../client/ui-command/src/client/directory.ts | 2 +- .../src/client/skeleton/InputBar.tsx | 2 +- packages/client/ui-goal/src/client/index.ts | 2 +- .../client/ui-model/src/client/service.ts | 2 +- packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 4 +- packages/client/ui-models/README.zh.md | 4 +- .../ui-primitives/src/markdown/highlight.ts | 2 +- .../ui-question/src/client/contract/slots.ts | 4 +- .../client/tool/models/search-card-model.ts | 11 +-- .../tool/toolviews/ask-question-row.tsx | 4 +- .../src/client/tool/toolviews/todo-row.tsx | 2 +- .../code-runtime-worker/src/bootstrap.ts | 2 +- .../context/time-context/src/invariant.ts | 4 +- .../time-context/tests/invariant.spec.ts | 10 +-- packages/core/session/src/index.ts | 4 +- packages/core/system-prompt/src/index.ts | 2 +- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 6 +- packages/core/tools/README.zh.md | 6 +- packages/core/tools/src/code-mode.ts | 42 +++++----- packages/core/tools/src/index.ts | 13 +-- packages/core/tools/src/json-schema.ts | 4 +- packages/core/tools/src/py-types.ts | 2 +- packages/core/tools/tests/code-mode.spec.ts | 12 +-- .../credentials-local/src/index.ts | 2 +- packages/e2b/subprocess-e2b/README.i18n.yaml | 4 +- packages/e2b/subprocess-e2b/README.md | 2 +- packages/e2b/subprocess-e2b/README.zh.md | 2 +- packages/experimental/AGENTS.md | 4 +- packages/fs/fs-policy/src/types.ts | 12 +-- packages/fs/tool-fs-search/src/grep.ts | 2 +- packages/fs/tool-fs/src/edit.ts | 2 +- packages/fs/tool-fs/src/write.ts | 2 +- packages/goal/goal/src/fold.ts | 10 +-- packages/goal/goal/tests/goal.spec.ts | 10 +-- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 8 +- packages/host/apiproxy/README.zh.md | 10 +-- .../host/apiproxy/src/api/approvals.schema.ts | 2 +- .../host/apiproxy/src/api/commands.schema.ts | 2 +- .../host/apiproxy/src/api/events.schema.ts | 2 +- packages/host/apiproxy/src/api/rpc.schema.ts | 4 +- .../host/apiproxy/src/api/sessions.schema.ts | 2 +- .../directory-picker-auto/README.i18n.yaml | 4 +- packages/host/directory-picker-auto/README.md | 2 +- .../host/directory-picker-auto/README.zh.md | 2 +- .../host/directory-picker/README.i18n.yaml | 4 +- packages/host/directory-picker/README.md | 4 +- packages/host/directory-picker/README.zh.md | 4 +- packages/host/webserver/README.i18n.yaml | 4 +- packages/host/webserver/README.md | 4 +- packages/host/webserver/README.zh.md | 4 +- packages/host/webserver/src/index.ts | 15 ++-- .../interaction/permission/src/invariant.ts | 2 +- .../user-interaction/README.i18n.yaml | 4 +- .../interaction/user-interaction/README.md | 2 +- .../interaction/user-interaction/README.zh.md | 2 +- .../interaction/user-interaction/src/types.ts | 8 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/catalog.ts | 6 +- packages/llm/llm-pi-ai/src/config.ts | 8 +- packages/llm/llm/src/index.ts | 4 +- packages/llm/llm/src/message.ts | 10 +-- packages/llm/llm/src/types.ts | 8 +- packages/llm/token-meter/src/index.ts | 4 +- packages/plan/plan-mode/README.i18n.yaml | 4 +- packages/plan/plan-mode/README.md | 10 +-- packages/plan/plan-mode/README.zh.md | 12 +-- packages/plan/plan-mode/src/index.ts | 79 ++++++++++--------- packages/plan/plan-mode/src/types.ts | 4 +- .../plan/plan-mode/tests/projection.spec.ts | 2 +- .../sandbox/sandbox-local/README.i18n.yaml | 4 +- packages/sandbox/sandbox-local/README.md | 2 +- packages/sandbox/sandbox-local/README.zh.md | 2 +- packages/sandbox/sandbox-local/src/index.ts | 2 +- .../sandbox/sandbox-policy/src/invariant.ts | 2 +- packages/scaffold/client/src/api.ts | 2 +- .../helper/src/documents/tsconfig-file.ts | 2 +- .../helper/src/features/define-feature.ts | 2 +- .../scaffold/helper/src/features/feature.ts | 2 +- .../repository-plugin/src/index.ts | 2 +- .../tool-cordis/src/api-catalog.ts | 22 +++--- .../tool-cordis/src/sandbox.ts | 4 +- .../session-query/session-query/src/index.ts | 4 +- .../session-persistence-jsonl/src/format.ts | 4 +- .../session-persistence/README.i18n.yaml | 4 +- .../session/session-persistence/README.md | 8 +- .../session/session-persistence/README.zh.md | 8 +- .../session/session-projection/src/index.ts | 4 +- .../session-telemetry-otel/src/index.ts | 16 ++-- .../session-telemetry/README.i18n.yaml | 4 +- packages/session/session-telemetry/README.md | 4 +- .../session/session-telemetry/README.zh.md | 4 +- .../session/session-telemetry/src/index.ts | 11 ++- packages/settings/settings/README.i18n.yaml | 4 +- packages/settings/settings/README.md | 2 +- packages/settings/settings/README.zh.md | 2 +- packages/settings/settings/src/index.ts | 24 +++--- .../settings/settings/tests/settings.spec.ts | 10 +-- packages/storage/storage-domain/src/spec.ts | 2 +- packages/storage/storage/src/backend.ts | 12 +-- .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.md | 2 +- packages/subagent/subagent-acp/README.zh.md | 2 +- packages/subagent/subagent-acp/src/index.ts | 2 +- packages/subagent/subagent-acp/src/run.ts | 4 +- packages/subagent/subagent/src/types.ts | 5 +- packages/support/acp-snapshot/src/suite.ts | 2 +- packages/support/invariants/README.i18n.yaml | 4 +- packages/support/invariants/README.md | 4 +- packages/support/invariants/README.zh.md | 4 +- packages/support/llm-replay/src/index.ts | 4 +- packages/tasks/tasks-local/src/index.ts | 4 +- .../tasks/tasks-local/tests/tasks.spec.ts | 2 +- packages/todo/tool-todo/src/invariant.ts | 2 +- packages/typert/loader/src/index.ts | 6 +- packages/typert/loader/tests/loader.spec.ts | 2 +- packages/util/retention/src/index.ts | 6 +- packages/web/tool-web/src/index.ts | 2 +- packages/web/web-fetch-local/src/index.ts | 2 +- packages/web/web-fetch-local/src/provider.ts | 2 +- packages/web/web/src/index.ts | 2 +- packages/web/web/src/types.ts | 2 +- .../workflow/tool-workflow/README.i18n.yaml | 4 +- packages/workflow/tool-workflow/README.md | 2 +- packages/workflow/tool-workflow/README.zh.md | 2 +- packages/workflow/tool-workflow/src/index.ts | 2 +- .../workflow-workerthread/src/meta.ts | 4 +- .../workflow-workerthread/src/realm.ts | 14 ++-- .../workflow-workerthread/src/runtime.ts | 2 +- packages/workflow/workflow/src/types.ts | 8 +- python/README.i18n.yaml | 4 +- python/README.md | 2 +- python/README.zh.md | 2 +- scripts/AGENTS.md | 2 +- scripts/archived-agent-notes.ts | 2 +- scripts/client-bundle-purity.spec.ts | 2 +- scripts/cordis-walk.ts | 2 +- scripts/coverage-exempt.ts | 2 +- scripts/doc-typecheck-paths.ts | 2 +- scripts/doc-typecheck.ts | 2 +- scripts/gen-config-catalog.ts | 12 +-- scripts/gen-cordis-catalog.ts | 16 ++-- scripts/gen-doc-graphs.ts | 18 ++--- scripts/gen-third-party-notices.spec.ts | 4 +- scripts/gen-third-party-notices.ts | 4 +- scripts/gen-translation-brief.ts | 2 +- scripts/lint-rule-fingerprint.spec.ts | 4 +- scripts/package-invariants.ts | 4 +- scripts/run-gates.ts | 4 +- .../request-response.expected.json | 14 ++-- scripts/test-invariants.spec.ts | 2 +- scripts/test-invariants.ts | 2 +- scripts/translation-pairing-git.ts | 2 +- scripts/translation-pairing.ts | 4 +- scripts/translation-prompt.spec.ts | 4 +- scripts/translation-prompt.ts | 4 +- scripts/verify-agent-note-classification.ts | 2 +- scripts/verify-agent-note-format.ts | 2 +- scripts/verify-archived-agent-notes.ts | 2 +- scripts/verify-config-source-ownership.ts | 2 +- scripts/verify-export-jsdoc.ts | 6 +- scripts/verify-package-invariants.ts | 2 +- .../verify-package-readme-model-experience.ts | 4 +- scripts/verify-translation-pairing.ts | 2 +- skills/dsh-upgrade/SKILL.md | 2 +- 459 files changed, 1342 insertions(+), 1329 deletions(-) diff --git a/.agents/notes/AGENTS.md b/.agents/notes/AGENTS.md index e8e1a0ef66..66997859a8 100644 --- a/.agents/notes/AGENTS.md +++ b/.agents/notes/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — Agent Notes -Agent Notes are effectively RFCs written by agents: durable proposals and decision records that preserve rationale, alternatives, consequences, and verification contracts. Follow the [documentation standard](../../docs/AGENTS.md) and the [Agent Note contract](README.md). +Agent Notes are effectively RFCs written by agents: durable proposals and decision records that preserve rationale, alternatives, consequences, and required verification. Follow the [documentation standard](../../docs/AGENTS.md) and the [Agent Note rules](README.md). **Every new Agent Note triggers a supersession check.** Search the active tree for older notes covering the same decision or mechanism, classify any full or partial supersession with [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md), and archive every qualifying implemented triplet in the same PR. Keep partial supersessions active and cross-linked. diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index 56eeb36b4d..cd767c17bc 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/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 .agents/notes/README.md -README.md: d3a8943a78238d974d54028e38b773e932429b0e -README.zh.md: 4b3a1ee57ea61a8e8ba4d01cf7c719bbf8440e30 +README.md: ae8e4724d610c97d74910d7dec5c95af69e93281 +README.zh.md: d9989dd1d722185145099b706169dfce19559b52 diff --git a/.agents/notes/README.md b/.agents/notes/README.md index d3a8943a78..ae8e4724d6 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -One kind of design doc lives here. An **Agent Note** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. This file is the entry point and contract: where Agent Notes live, when to write one, and [the in-file format](#the-file-format). +One kind of design doc lives here. An **Agent Note** records a decision or proposal that affects this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. This file defines where Agent Notes live, when to write one, and [the in-file format](#the-file-format). ## Layout and naming @@ -43,13 +43,13 @@ Once sealed, every archived triplet is permanently frozen. Do not edit, translat ## When to write one -Every non-trivial change MUST add or update at least one Agent Note in the same PR. A change is non-trivial when it alters behavior, architecture, a cross-file or cross-package contract, process or tooling, testing strategy, an on-disk, wire, or configuration format, or another decision a maintainer may reasonably revisit. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). +Every non-trivial change MUST add or update at least one Agent Note in the same PR. A change is non-trivial when it alters behavior, architecture, a contract shared across files or packages, process or tooling, testing strategy, an on-disk, wire, or configuration format, or another decision a maintainer may reasonably revisit. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). -Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no behavioral, contractual, structural, process, or rationale change is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). +Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no change to behavior, contracts, structure, process, or rationale is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). -An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, verification contract, and named coverage gap; repair every inbound link; and delete the Chinese counterpart and consistency record in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale. +An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, required verification, and named coverage gap; repair every inbound link; and delete the Chinese counterpart and consistency record in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale. -A feature-addition note may be consolidated into the later removal note only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that verify absence may remain. The removal owner preserves the original motivation, why it no longer justified the feature, alternatives to full removal, the capability given up, conditions for reintroduction, and verification of complete absence. Obsolete implementation inventories and tests that only verified the deleted behavior are not current verification contracts. Removing one transport, default, implementation, or presentation is partial supersession, as is any surviving durable data or compatibility handling. +A feature-addition note may be consolidated into the later removal note only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that verify absence may remain. The removal owner preserves the original motivation, why it no longer justified the feature, alternatives to full removal, the capability given up, conditions for reintroduction, and verification of complete absence. Obsolete implementation inventories and tests that only verified the deleted behavior are not current verification evidence. Removing one transport, default, implementation, or presentation is partial supersession, as is any surviving durable data or compatibility handling. ## The file format @@ -122,4 +122,4 @@ Moving a file between lifecycle folders means updating the `Status:` line and re ### Chinese counterparts -A `.zh.md` counterpart mirrors its English sibling's structure section-for-section under the [i18n contract](../../docs/i18n/README.md); the machine-checked header tokens (`# Agent Note: ` and the `Status:` line) stay in English verbatim. The format gate skips `.zh.md` files — the pairing gate owns their consistency. +A `.zh.md` counterpart mirrors its English sibling's structure section-for-section under the [i18n contract](../../docs/i18n/README.md); the machine-checked header tokens (`# Agent Note: ` and the `Status:` line) stay in English verbatim. The format gate skips `.zh.md` files — the pairing gate checks their consistency. diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index 4b3a1ee57e..d9989dd1d7 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这里存放一类设计文档。**Agent Note** 记录塑造本代码库的决策或提案:代码和文档无法承载的*为什么*以及*放弃了什么*。本文件是入口和约定:Agent Note 存放在哪里、何时需要写一份,以及[文件内格式](#the-file-format)。 +这里存放一类设计文档。**Agent Note** 记录影响本代码库的决策或提案:代码和文档无法承载的*为什么*以及*放弃了什么*。本文件规定 Agent Note 存放在哪里、何时需要写一份,以及[文件内格式](#the-file-format)。 ## 布局与命名 @@ -49,9 +49,9 @@ 更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、约定、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 -被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、验证约定和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件和一致性记录。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。 +被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、必需的验证和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件和一致性记录。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。 -只有当一项功能已从生产代码、配置、schema、持久化格式或协议格式、迁移和兼容行为中完全消失,当前文档不再将其描述为可用,且没有测试把它作为受支持行为来执行时,新增该功能的 Agent Note 才可合并进后续的移除记录。移除决策的依据和验证该功能已不存在的测试可以保留。移除决策的持有记录必须保留最初动机、为什么该动机已不足以证明保留该功能的合理性、完全移除之外的备选方案、放弃的能力、重新引入的条件,以及证明已彻底移除的验证。过时的实现清单和只验证已删除行为的测试不属于当前验证约定。仅移除一种传输、默认值、实现或展示属于部分取代;仍有任何持久数据或兼容处理也同样如此。 +只有当一项功能已从生产代码、配置、schema、持久化格式或协议格式、迁移和兼容行为中完全消失,当前文档不再将其描述为可用,且没有测试把它作为受支持行为来执行时,新增该功能的 Agent Note 才可合并进后续的移除记录。移除决策的依据和验证该功能已不存在的测试可以保留。移除决策的持有记录必须保留最初动机、为什么该动机已不足以证明保留该功能的合理性、完全移除之外的备选方案、放弃的能力、重新引入的条件,以及证明已彻底移除的验证。过时的实现清单和只验证已删除行为的测试不属于当前验证证据。仅移除一种传输、默认值、实现或展示属于部分取代;仍有任何持久数据或兼容处理也同样如此。 <a id="the-file-format"></a> @@ -126,4 +126,4 @@ Status: <status> ### 中文对侧文件 -`.zh.md` 对侧文件按 [i18n 约定](../../docs/i18n/README.md)逐章节镜像其英文对侧文件的结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。 +`.zh.md` 对侧文件按 [i18n 约定](../../docs/i18n/README.md)逐章节与其英文对侧文件保持相同结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件;配对门禁检查它们的一致性。 diff --git a/.agents/notes/implemented/AGENTS.md b/.agents/notes/implemented/AGENTS.md index b8da5dc8ef..5a22674a96 100644 --- a/.agents/notes/implemented/AGENTS.md +++ b/.agents/notes/implemented/AGENTS.md @@ -10,4 +10,4 @@ When a shipped note is unlikely to guide future work, archive its complete tripl ### This is not a license to rewrite the *decision* -Update factual realization in place. A reversal of the decision or its rationale requires a new Agent Note and cross-link; a fully superseded old note may be deleted only through the consolidation rule in the [Agent Note contract](../README.md). +Update factual realization in place. A reversal of the decision or its rationale requires a new Agent Note and cross-link; a fully superseded old note may be deleted only through the consolidation rule in the [Agent Note rules](../README.md). diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index d47c50cdb7..c7be4bbdf1 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 705b0df5feb5fedaae4d198aed71758b54586e93 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: b28b08b4b9da058e01af62e610d4e226d794151f +2026-07-19-gui-layering-and-rpc-protocol.md: f9c95176321496e965a95b6358d6feaa8466fe89 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 7d20c5a2662c9036382b30a96bc9973c8f0349bd diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 705b0df5fe..f9c9517632 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -8,12 +8,12 @@ English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) ## Problem -We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product UI shapes are coming — Web (server), Electron, and others. We call these shapes Clients, uniformly, and want the following capabilities: +We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product clients are coming — Web (server), Electron, and others. We call them Clients and want the following capabilities: - One `dsh` process supporting both `dsh web` (serve) and `dsh run` (headless) — one process, two modes (a design reservation) -- Launching inside Electron with the same Web technology shape as `dsh web` +- Launching inside Electron with the same Web technologies as `dsh web` -That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly. +That demands a stable layered responsibility model in the engineering codebase, so future clients plug in cleanly. At the same time the physical channels differ per consumer (browser HTTP/WebSocket, in-process fetch/SSE, IPC later), so we also need a channel-independent message model and a single contract source of truth — "adding a method" and "swapping a carrier" must not entangle each other, and every message on the wire must be type-validatable, observable, and reconcilable. @@ -29,19 +29,19 @@ Directories layer as follows: - **Pure libraries** (`ui-slots`, `web-react`, `ui-primitives`, plus the `loader` kernel package): ordinary root-index packages, statically bundled into the shell; the first three are seeded into the module table. - **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dshClient` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else. - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. -- `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures. +- `apps/` holds the externally exported applications, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. - - A future Electron shape reuses the same web client packages over an IPC fetch carrier. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. + - A future Electron application reuses the same web client packages over an IPC fetch carrier. ``` -apps/* (application shapes: apps/web = vite app, apps/cli = bin dispatch) +apps/* (applications: apps/web = vite app, apps/cli = bin dispatch) │ consume ▼ packages/host/* packages/client/* apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives runtime assembly / host entity dshClient plugins ×8 (node half = empty apply, - webserver web-shape HTTP carriage client half = src/client/) + webserver Web HTTP carriage client half = src/client/) │ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths ▼ │ (type-only + the client base class) harness core packages ──────────────────┘ (types reach the browser via import type) @@ -51,12 +51,12 @@ Direction discipline (every rule auditable from package deps): - `runtime → apiproxy` is one-way; apiproxy depends only on type definitions. - Client-side packages **never import** host-side package runtime (they consume only the two browser-safe subpaths `/api` and `/client`). -- `webserver` does not depend on `runtime`: it provides a `{ fetch }`-shaped implementation — "webserver ← runtime" is a runtime injection relationship, not a package dependency. +- `webserver` does not depend on `runtime`: it provides an implementation of the `{ fetch }` interface — "webserver ← runtime" is a runtime injection relationship, not a package dependency. - Cross-package client imports use the `/client` subpath for plugin packages, and between plugin packages they are type-only — a cross-plugin value import is a build error at the tsdown purity gate (value cooperation goes through cordis services; the [client plugin loading note](2026-07-23-client-plugin-loading-model.md) owns the edge rules). TypeScript checks in **two aggregate programs** referenced by a solution root (`tsconfig.json` = solution; `tsconfig.host.json` = host side + tests, excluding `packages/client`; `tsconfig.client.json` = client packages and their tests): both sides merge the cordis `Context` interface under the same keys (`sessions`, `loader`) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs ([topology](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md)). -On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Node dependencies, browser-importable); wire messages unify under a **bidirectional model** — each logical message is shaped by "who initiates × request/response" (two axes, four cells, called the four quadrants below), decoupled from the physical channel; clients all inherit `AbstractApiClient` (protocol invariants live entirely in the base class, platform differences are just the `doFetch` transport aspect). +On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Node dependencies, browser-importable); wire messages unify under a **bidirectional model** — each logical message is classified by "who initiates × request/response" (two axes, four cells, called the four quadrants below), decoupled from the physical channel; clients all inherit `AbstractApiClient` (protocol invariants live entirely in the base class, platform differences are just the `doFetch` transport aspect). #### Layer roles @@ -64,22 +64,22 @@ On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Nod |---|---|---|---| | Front layer | `dsh-host-apiproxy` | TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx | | Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly | -| Carrier layer | `dsh-host-webserver` | Web-shape HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | +| Carrier layer | `dsh-host-webserver` | Web HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | | Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell | | Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture note | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy | -| Application shape | `@deepseek-ai/dsh` (apps/cli) + `dsh-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per shape (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Shapes dynamic-import so they never load each other; workspace knowledge like dist location stays in the app | +| Application | `@deepseek-ai/dsh` (apps/cli) + `dsh-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per application (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Applications use dynamic imports so they never load each other; workspace knowledge like dist location stays in the app | #### Naming rule Packages under `packages/host/*` and `packages/client/*` **must carry the directory-group prefix in the package name**: host/runtime → `dsh-host-runtime`, client/runtime → `dsh-client-runtime`. The directory name does not repeat the group prefix (host/ already expresses it). The package-name tail therefore ≠ the directory name, so the `dsh-*` wildcard in tsconfig.base.json (which resolves by directory name) misses them — **each package in these two groups needs an explicit paths entry**, including separate entries for the client packages' `/client` subpaths so source-level resolution matches the exports map. -#### How to integrate a new shape (operational checklist) +#### How to integrate a new application (operational checklist) 1. **Pick a fetch impersonation**: browser same-origin HTTP / in-process `host.handler.fetch` injection / your own transport-aspect subclass (e.g. future Electron IPC, see the "Subclass table" below). -2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the shape's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app. +2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the application's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app. 3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports. -The two existing shapes preserve the boundary: the Web shape mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem, mount via `ctx.plugin(entry-point plugin)` directly, and wear no fetch. +The two existing applications preserve the division: the Web application mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem and mount directly via `ctx.plugin(entry-point plugin)` without fetch. ## Message protocol @@ -169,7 +169,7 @@ The remaining methods (`session.create`/`session.history`/`session.rename`/`sess ### Frames (server→client, named unions) -Two logical streams: the mux stream (`/api/events.mux`, all-session aggregate) and the host stream (`/api/events.host`, host-level events). The browser consumes one downlink WebSocket per stream, while the in-process fetch carrier retains SSE to preserve the same shape; see the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) for the physical boundary. One example frame row: +Two logical streams: the mux stream (`/api/events.mux`, all-session aggregate) and the host stream (`/api/events.host`, host-level events). The browser consumes one downlink WebSocket per stream, while the in-process fetch carrier retains SSE with the same event framing; see the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) for the physical boundary. One example frame row: | frame type | payload | when | |---|---|---| @@ -216,7 +216,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation | Subclass | Package | doFetch | Purpose | |---|---|---|---| | `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh run` drives core directly | -| `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser shape; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser client; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | | IPC bridge subclass (hypothetical example — no such shell exists) | an Electron shell | IPC serialization round trip | would swap only doFetch; contract and base class unchanged | @@ -234,15 +234,15 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation ## Consequences -Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. Ordinary unary calls remain bounded, while `host.pickDirectory` and `command.execute` may stay pending until the operation finishes or caller/connection cancellation arrives; this accepts that a non-cooperative user-paced operation can hang its request rather than treating valid operation duration as transport failure. The other accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved methods (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives. +Every client consumes one contract: adding a unary method is a five-step mechanical change from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. Ordinary unary calls remain bounded, while `host.pickDirectory` and `command.execute` may stay pending until the operation finishes or caller/connection cancellation arrives; this accepts that a non-cooperative user-paced operation can hang its request rather than treating valid operation duration as transport failure. The other accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved methods (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives. ## Alternatives considered | Rejected | One-line reason | |---|---| -| Packaging by "product shape" (a web family, an electron family) | What shapes share is host/client capability, not the shape itself; capability-provider layering means a new shape needs zero new packages | +| Packaging by product (a web family, an electron family) | Products share host/client capabilities rather than an application implementation; capability-provider layering means a new application needs zero new packages | | A package per mixture (e.g. a standalone headless package) | A mixture has exactly one consumer (its own app); packaging it is ownerless abstraction, while assembly in the app is readable and disposable | -| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Client shapes require wire validation, observability, and multi-client consistency. Direct headless is a local entry point with no client boundary and uses the public Agent/Session seams rather than a client command plane | +| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Clients require wire validation, observability, and multi-client consistency. Direct headless is a local entry point with no client boundary and uses the public Agent/Session seams rather than a client command plane | | webserver depending on runtime (saving the handler injection) | Structural-typing injection keeps webserver reusable by sidecars/tests with zero workspace deps; a package dependency would drag assembly knowledge into the carrier layer | | Package names without the group prefix (continuing dsh-<tail>) | `dsh-runtime`/`dsh-web-ui` lose their belonging in the flat npm namespace; the cost is one explicit paths entry per package | | Reusing the in-repo JSON-RPC 2.0 (dsh-jsonrpc) | Numeric error codes degrade to a single fallback code, contracts get aligned by hand in two copies, and naming drifts without a convention | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index b28b08b4b9..7d20c5a266 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -8,11 +8,11 @@ Status: implemented ## Problem -需要提供 UI 对接层,除已有 ACP(Agent Client Protocol)/stdio 基线外,还需要 Web(server)、Electron 等其他产品 UI 形态。我们把这些形态统一称为 Client。希望具备以下能力: +需要提供 UI 对接层,除已有 ACP(Agent Client Protocol)/stdio 基线外,还需要 Web(server)、Electron 等其他产品客户端。我们把它们统一称为 Client。希望具备以下能力: - 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh run`(headless),一个进程两种模式(设计预留) -- 以与 `dsh web` 同构的 Web 技术形态,在 Electron 中启动 +- 在 Electron 中使用与 `dsh web` 相同的 Web 技术启动 -那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。 +那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client。 同时各消费端的物理通道不同(浏览器 HTTP/WebSocket、进程内 fetch/SSE、将来 IPC),还需要一个通道无关的消息模型和单一约定事实源,让「加一个方法」「换一种载体」互不牵连,且 wire 上的每条消息可类型校验、可观测、可对账。 @@ -27,19 +27,19 @@ Status: implemented - **纯库**(`ui-slots`、`web-react`、`ui-primitives`,外加内核包 `loader`):普通根入口包,静态打包进壳;前三者播种进模块表。 - **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dshClient` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。 - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 -- `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。 +- `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 - - 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。 + - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 + - 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。 ``` -apps/* (application shapes: apps/web = vite app, apps/cli = bin dispatch) +apps/* (applications: apps/web = vite app, apps/cli = bin dispatch) │ consume ▼ packages/host/* packages/client/* apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives runtime assembly / host entity dshClient plugins ×8 (node half = empty apply, - webserver web-shape HTTP carriage client half = src/client/) + webserver Web HTTP carriage client half = src/client/) │ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths ▼ │ (type-only + the client base class) harness core packages ──────────────────┘ (types reach the browser via import type) @@ -54,7 +54,7 @@ harness core packages ──────────────────┘ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md))。 -协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息由「谁发起 × request/response」定形(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。 +协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息按「谁发起 × request/response」分类(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。 #### 分层角色 @@ -62,22 +62,22 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. |---|---|---|---| | 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/:handler + 客户端基类) | 做简单、所有接入方都要;Node/浏览器皆可 import;协议内容见下文「消息协议」起各节;client 不得经 ctx 绕开 api | | 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 | -| 承载层 | `dsh-host-webserver` | Web 形态 HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | +| 承载层 | `dsh-host-webserver` | Web HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | | client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 | | client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构笔记 | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy | -| 应用态 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-frontend`(apps/web,vite 应用) | bin 粗分发 + 每形态一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 形态间动态 import 互不加载;dist 定位等 workspace 知识留在 app | +| 应用 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-frontend`(apps/web,vite 应用) | bin 粗分发 + 每个应用一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 各应用使用动态 import,因此不会互相加载;dist 定位等 workspace 知识留在 app | #### 命名规则 `packages/host/*` 与 `packages/client/*` 下的包名**必须含目录组前缀**:host/runtime → `dsh-host-runtime`、client/runtime → `dsh-client-runtime`。目录名不重复组前缀(host/ 已表达)。因此包名尾段 ≠ 目录名,tsconfig.base.json 的 `dsh-*` 通配(按目录名解析)命不中——**这两组的每包需显式 paths 条目**,且 client 各包的 `/client` 子路径要单列条目,使源码级解析与 exports map 一致。 -#### 怎么接入一个新形态(操作清单) +#### 怎么接入一个新应用(操作清单) 1. **选 fetch 伪造方式**:浏览器同源 HTTP / 进程内 `host.handler.fetch` 注入 / 自写传输切面子类(如将来 Electron IPC,见下文「子类表」)。 -2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该形态私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。 +2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该应用私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。 3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。 -现有两种形态保持这一边界:Web 形态挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不套 fetch。 +现有两个应用保持这一区分:Web 应用挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不使用 fetch。 ## 消息协议 @@ -214,7 +214,7 @@ export type ResponseValue<K> = | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| | `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh run` 直接驱动 core | -| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器形态;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | IPC 桥子类(假想示例——尚无此形态) | Electron 壳 | IPC 序列化往返 | 只需换 doFetch,约定/基类零改 | @@ -232,15 +232,15 @@ export type ResponseValue<K> = ## Consequences -所有 client 形态消费同一约定:加一个 unary 方法是从单一签名辐射的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。普通 unary 调用仍受时限约束,而 `host.pickDirectory` 与 `command.execute` 可保持挂起,直到操作完成或调用方/连接取消到来;若由用户掌控节奏的操作不自行结束,请求可能一直挂起,这是为避免把合理的操作时长视为传输失败而接受的代价。其余接受的代价:两组包需要显式 tsconfig paths 条目;预留方法(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。 +所有 client 使用同一约定:加一个 unary 方法是从单一签名出发的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。普通 unary 调用仍受时限约束,而 `host.pickDirectory` 与 `command.execute` 可保持挂起,直到操作完成或调用方/连接取消到来;若由用户掌控节奏的操作不自行结束,请求可能一直挂起,这是为避免把合理的操作时长视为传输失败而接受的代价。其余接受的代价:两组包需要显式 tsconfig paths 条目;预留方法(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。 ## Alternatives considered | 放弃项 | 一句话理由 | |---|---| -| 按「产品形态」分包(web 一族、electron 一族) | 形态间共享的是 host/client 两侧能力而非形态本身;能力支持方分层让新形态零新包 | +| 按产品分包(web 一族、electron 一族) | 产品共享的是 host/client 两侧能力,而不是某个应用实现;能力支持方分层让新应用零新包 | | 混合体建包(如 headless 独立包) | 混合体只有一个消费者(它自己的 app),建包是无主抽象;拼装写在 app 里可读可弃 | -| 消费型 client 直连 ctx(省 apiproxy 一层) | client 形态需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地入口,使用公开的 Agent/Session seam,而不是 client 命令面 | +| 消费型 client 直连 ctx(省 apiproxy 一层) | client 需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地入口,使用公开的 Agent/Session seam,而不是 client 命令面 | | webserver 依赖 runtime(省 handler 注入) | 结构 typing 注入让 webserver 可被 sidecar/测试复用且零 workspace 依赖;包依赖会把装配知识拖进承载层 | | 包名不带组前缀(沿用 dsh-<尾段>) | `dsh-runtime`/`dsh-web-ui` 在扁平 npm 命名空间里失去归属信息;代价只是每包一条显式 paths | | 复用仓内 JSON-RPC 2.0(dsh-jsonrpc) | 数字错误码退化成单码兜底、约定双份人肉对齐、命名无 convention 自然漂移 | diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 8979270e36..0b2df8cc51 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: d656d329cbc5b3dfbae2561775ed1afc878cebd0 -2026-08-04-configuration-source-ownership.zh.md: 29ef3b83d18d8836b28e4c151ad44c52542b0a11 +2026-08-04-configuration-source-ownership.md: 8ec750de2efaf148fe44a58b415d7da44b87bdc6 +2026-08-04-configuration-source-ownership.zh.md: 64daa54843d55c6f4bde5a8fdece66dbbe835479 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index d656d329cb..8ec750de2e 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -60,7 +60,7 @@ The line is that these take effect with no user action, before any turn, outside ## Alternatives considered -**Unify credentials into the non-secret ordering, by who authored each source.** Attempted and abandoned: it reads well, but the settings seam already fixes composition *below* the user section, so "authored by deployment" is not a tier the seam can express — and moving `.credentials.yaml` above the launching environment would take away the one override CI, containers, and a per-run `DEEPSEEK_API_KEY=…` depend on. Two orderings that each say why they are shaped that way beat one that describes neither accurately. +**Unify credentials into the non-secret ordering, by who authored each source.** Attempted and abandoned: it reads well, but the settings seam already fixes composition *below* the user section, so "authored by deployment" is not a tier the seam can express — and moving `.credentials.yaml` above the launching environment would take away the one override CI, containers, and a per-run `DEEPSEEK_API_KEY=…` depend on. Two orderings that each explain their precedence beat one that describes neither accurately. **Withhold routing and credentials from the invoking project until it is explicitly trusted.** Rejected as the product's stance: a checkout is trusted by default, with no prompt and no stored trust record. The residual is real and worth naming — cloning a repository that carries a `.env` naming another endpoint or key routes that session through it — and a later project-trust gate is where that gets addressed, not a rule that makes the common case require ceremony. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 29ef3b83d1..64daa54843 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -62,7 +62,7 @@ inherited process environment (read-only, wins) ## Alternatives considered -**按「来源由谁书写」把凭据并入非机密顺序。** 尝试过并放弃:它读起来很顺,但 settings seam 已经把 composition 固定在用户 section *之下*,因此「由部署方写入」根本不是该 seam 能表达的一层;而把 `.credentials.yaml` 抬到启动环境之上,会夺走 CI、容器和一次性 `DEEPSEEK_API_KEY=…` 所依赖的那唯一一种覆盖。两条各自说清自身形状成因的顺序,好过一条两边都描述不准的顺序。 +**按「来源由谁书写」把凭据并入非机密顺序。** 尝试过并放弃:它读起来很顺,但 settings seam 已经把 composition 固定在用户 section *之下*,因此「由部署方写入」根本不是该 seam 能表达的一层;而把 `.credentials.yaml` 抬到启动环境之上,会夺走 CI、容器和一次性 `DEEPSEEK_API_KEY=…` 所依赖的那唯一一种覆盖。两条各自说明优先顺序的规则,好过一条两边都描述不准的规则。 **在项目被显式信任之前,不给它路由与凭据能力。** 作为产品立场被否决:checkout 默认可信,不询问,也不存储信任记录。残留风险是真实的、值得写明——克隆一个携带 `.env`、其中指定了另一个 endpoint 或密钥的仓库,会让该会话经由它——处理它的地方是日后的 project trust 门禁,而不是一条让常见情形都要走仪式的规则。 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml index a334bdc7d5..f8cf184ea9 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-15-code-mode.md -2026-06-15-code-mode.md: 5ad7be9506c2356f90050b86d85aa77f602946ce -2026-06-15-code-mode.zh.md: 1902c6ba86b6b8d3fecd06c417347a9c082f67b4 +2026-06-15-code-mode.md: 51c53c56f5755d56f49d8e5166a1ceeef0ffc202 +2026-06-15-code-mode.zh.md: dbf8d409dea152b39d215f5dd636989c3f4fa0bb diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.md index 5ad7be9506..51c53c56f5 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.md @@ -18,7 +18,7 @@ Tool presentation belongs to the registry that owns tool visibility: implementin Three decisions, each elaborated in its own section below: -1. **Code Mode is a first-class presentation mode of `ToolRegistry`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (the default, contributing the visible capability schemas), `'code'` (the registry contributes only its reserved `run_code` transport plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas and the transport + SDK). The registry shapes its canonical contribution at the source; the cooperative prompt-assembly result remains authoritative, and the logged request header records exactly that returned presentation. +1. **Code Mode is a first-class presentation mode of `ToolRegistry`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (the default, contributing the visible capability schemas), `'code'` (the registry contributes only its reserved `run_code` transport plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas and the transport + SDK). The registry constructs its canonical contribution at the source; the cooperative prompt-assembly result remains authoritative, and the logged request header records exactly that returned presentation. 2. **Code execution is a capability seam** — `packages/code-runtime/` contains the Service Definition package `@deepseek-ai/dsh-code-runtime`, which owns `ctx.codeRuntime` ([capability seams](../architecture/2026-06-13-capability-seams.md); Consumer = `dsh-tools`, with core-consumes-a-seam precedent in `agent-loop` → `dsh-llm`). The runtime knows nothing about tools: it is handed a program and named async bindings, runs the program, and reports `{ value, logs, error? }`. Language and substrate are backend properties, so a future Python or container backend is another Service provider package, not a redesign. 3. **The shipped implementation is `@deepseek-ai/dsh-code-runtime-worker`**: one fresh Node worker thread per run, executing the model's TypeScript after type-strip, with bindings bridged over the message port, an empty environment, configurable heap/output/time caps, and hard termination. Its trust posture is bash-equivalent by design — no unsafe-acknowledgement flags — because the harness already ships `dsh-bash-local`, which executes arbitrary model-written shell commands with strictly *more* ambient authority. @@ -120,11 +120,11 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem **The worker is not a hard security boundary.** Deliberate and documented (§Trust posture): posture equals the existing bash tool, containment exceeds it, gating uses the same approval and sandbox policies. Deployments needing more need a future `isolation: 'container'` backend — tracked as the seam's designed extension, not a TODO on this design. -**`stripTypeScriptTypes` is marked experimental.** It is the same engine (amaro/swc) behind Node's own native `.ts` execution, exposed as an API across this repo's whole engines range. Mitigations: the runtime's unit suite pins the behaviors relied on (position preservation, erasable-only rejection message shape loosely), the call sits behind one private function, and `amaro`/`sucrase` are drop-in replacements if the API shifts. The erasable-only subset is a model-facing contract line, and the error path is a working feedback loop, not a dead end. +**`stripTypeScriptTypes` is marked experimental.** It is the same engine (amaro/swc) behind Node's own native `.ts` execution, exposed as an API across this repo's whole engines range. Mitigations: the runtime's unit suite checks position preservation and the required parts of the erasable-only rejection message, the call sits behind one private function, and `amaro`/`sucrase` are direct replacements if the API shifts. The erasable-only subset is a model-facing input restriction, and the error tells the model how to correct the program. **Prompt cost of the SDK, especially under `'both'`.** The `.d.ts` can rival the native schemas it complements; `'both'` carries two representations. Prefix stability + provider caching amortize per-session cost; the mode is per-deployment; the Agent Note makes no unconditional-savings claim. Measured guidance (when to prefer which mode) is explicitly post-ship learning. -**Registry scope growth.** `dsh-tools` absorbs codegen, a tool, a bridge, and an event. Contained by module boundaries inside the package (`ts-types.ts`, `code-mode.ts` beside `schema.ts`/`json-schema.ts`/`presentation.ts`) and by the seam: everything substrate-shaped lives behind `ctx.codeRuntime`. +**Registry scope growth.** `dsh-tools` absorbs codegen, a tool, a bridge, and an event. Package modules separate these responsibilities (`ts-types.ts` and `code-mode.ts` beside `schema.ts`, `json-schema.ts`, and `presentation.ts`), while `ctx.codeRuntime` owns all code-runtime-specific implementation. **Large lossless JSON values can exhaust memory.** Tool bindings snapshot lossless JSON before dispatch and return canonical JSON resolutions whole. The runtime validates both sides of the worker port and applies no per-binding byte cap; structured-clone cost and process or worker memory are the practical bounds. The combined outer-output ledger for logs, the completion value, and a failure diagnostic is the only byte-capped boundary. diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md index 1902c6ba86..dbf8d409de 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md @@ -18,7 +18,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 三项决策,各自在下方独立小节中展开: -1. **Code Mode 是 `ToolRegistry`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头塑造其权威贡献;协作式提示词组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 +1. **Code Mode 是 `ToolRegistry`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头构建其权威贡献;协作式提示词组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含 Service Definition 包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`([能力 seam](../architecture/2026-06-13-capability-seams.md);Consumer = `dsh-tools`,core 消费 seam 的先例见 `agent-loop` → `dsh-llm`)。运行时对工具一无所知:它接收一段程序和命名的异步绑定,执行程序,报告 `{ value, logs, error? }`。语言和基底是后端属性,因此未来的 Python 或容器后端只是另一个 Service provider 包,而非重新设计。 3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker`**:每次运行 spawn 一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过消息端口桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`,后者以严格*更高*的环境权限执行模型编写的任意 shell 命令。 @@ -120,11 +120,11 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 **Worker 不是硬安全边界。** 有意为之且已文档化(§信任姿态):姿态等同于既有的 bash 工具,约束能力强于它,门禁使用相同的审批与沙箱策略。需要更强隔离的部署需要未来的 `isolation: 'container'` 后端——作为 seam 设计的扩展点跟踪,而非本设计的 TODO。 -**`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎(amaro/swc)相同,在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件固定了所依赖的行为(位置保持、可擦除限制的拒绝消息形状宽松匹配),调用位于一个私有函数之后,且 `amaro`/`sucrase` 是 API 变化时的直接替代品。仅可擦除子集是面向模型的约定线,错误路径是一个可工作的反馈循环,而非死胡同。 +**`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎(amaro/swc)相同,在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件会检查位置保持和可擦除限制拒绝消息中的必需部分;调用位于一个私有函数之后,且 `amaro`/`sucrase` 可在 API 变化时直接替换它。仅可擦除子集是面向模型的输入限制,错误消息会告诉模型如何修正程序。 **SDK 的提示词成本,尤其在 `'both'` 下。** `.d.ts` 可能与它补充的原生 schema 体量相当;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话成本;mode 是每部署的;本 Agent Note 不做无条件节省的声明。何时优先使用哪种模式的量化指导明确属于上线后学习。 -**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。包内的模块边界(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 共同约束了这一增长:所有基底相关的内容都在 `ctx.codeRuntime` 之后。 +**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。包内模块把这些职责分开(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`、`json-schema.ts`、`presentation.ts` 并列),所有 code-runtime 专用实现都由 `ctx.codeRuntime` 提供。 **大型无损 JSON 值可能耗尽内存。** 工具绑定会在分发前对无损 JSON 创建快照,并完整返回规范 JSON 返回值。运行时会校验 worker 端口两侧,但不对单次绑定设置字节数上限;结构化克隆成本以及进程或 worker 内存构成实际边界。只有包含日志、完成值和失败诊断的组合外层输出账本受字节数上限约束。 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index c68db1d6fd..57e84b91ff 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: 28ecf985db1289ce4f80570583eac6a051cd2c0b -2026-06-21-subagent-capability-seam.zh.md: 5f349ddcacebe66c346976b822c9dcc65bda8d18 +2026-06-21-subagent-capability-seam.md: 2b9ea93c40bd4b3547cfda83c3ad0bd52c047792 +2026-06-21-subagent-capability-seam.zh.md: 8b647fa51511e1c4cac55c3a9ea39b7e059bc381 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index 28ecf985db..2b9ea93c40 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -10,7 +10,7 @@ English | [中文](2026-06-21-subagent-capability-seam.zh.md) The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent was sketched in the `Agent`/`AgentLoop` interfaces ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an `Agent` handle so steering and event subscription work uniformly. -The distinctive requirement — the one that shapes the whole design — is that **multiple subagent implementations must coexist at runtime**. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports: +**Multiple subagent implementations must coexist at runtime.** A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports: - **in-process** — a child concrete `Agent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); - **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves); @@ -66,7 +66,7 @@ Each in-process subagent runs in its **own `Session`** (own id, `parentSession` ## Testing -Registry and tool tests replace only the nondeterministic child boundary with a package-local scripted provider while exercising the real `SubagentService`, lifecycle, task integration, and model-facing tool. Provider and consumer export shapes retain their Loader regression coverage for the failure described in [postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md). Registry tests cover reload safety, duplicate names, and start-time capability rejection; nested-agent scenarios replay keylessly through [per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md); in-process backends also have real-loop unit tests and a with-key e2e. +Registry and tool tests replace only the nondeterministic child with a package-local scripted provider while exercising the real `SubagentService`, lifecycle, task integration, and model-facing tool. Loader regression tests still cover the provider and consumer exports for the failure described in [postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md). Registry tests cover reload safety, duplicate names, and start-time capability rejection; nested-agent scenarios replay keylessly through [per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md); in-process backends also have real-loop unit tests and a with-key e2e. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 5f349ddcac..8b647fa515 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -10,7 +10,7 @@ Status: implemented harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent 将工作委派给另一个 agent。这一意图在 `Agent`/`AgentLoop` 接口中已有草案([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts)、[packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)):一个创建选项引用父 agent(fork = 用父会话的事件日志初始化子会话;spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering(中途引导)和事件订阅可以统一工作。 -决定整体设计走向的核心需求是:**多种 subagent 实现必须在运行时共存**。一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP)。传输方式: +**多种 subagent 实现必须在运行时共存。**一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP)。传输方式: - **进程内**:在同一个 `Context` 上创建一个具体的子 `Agent`(最廉价,且鉴于现有 agent 工厂几乎零成本); - **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例); @@ -66,7 +66,7 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在 ## 测试 -注册表与工具测试仅用包内脚本化提供方替换非确定性的子进程边界,同时运行真实的 `SubagentService`、生命周期、任务集成和面向模型的工具。提供方与消费方的 export 形状仍保留 Loader 回归覆盖,以防止[事故复盘(postmortem)0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md) 中描述的失败。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e 测试。 +注册表与工具测试仅用包内脚本化提供方替换非确定性的子 agent,同时运行真实的 `SubagentService`、生命周期、任务集成和面向模型的工具。Loader 回归测试仍覆盖提供方与消费方的 export,以防止[事故复盘(postmortem)0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)中描述的失败。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e 测试。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index 96e76345cc..c9ace8d3d4 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-30-hook-bridges.md -2026-06-30-hook-bridges.md: eecc1fc75c10618481231083f890e0e4b122c1ee -2026-06-30-hook-bridges.zh.md: dcc8f496cb3bc6ea7857ce804a9159cbfcbc3f08 +2026-06-30-hook-bridges.md: 0dce552820186755e9243bc5ce1dd3361e2e739d +2026-06-30-hook-bridges.zh.md: 2df3a47fd6219e4007e2cad8f2ffa9bef9600a87 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md index eecc1fc75c..0dce552820 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md @@ -8,7 +8,7 @@ English | [中文](2026-06-30-hook-bridges.zh.md) The harness's extension surface is its typed interception points ([the interception extension-points Agent Note](2026-06-30-interception-extension-points.md)): a "native hook" is just an ordinary cordis plugin subscribing to `agent/session-start`, `agent/pre-step`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-stopping`, `subagent/start`, or `subagent/end`. But users arrive with **existing** Claude Code (CC) and Codex hook configs — a `hooks.json` (or a settings file's `hooks` key) full of shell-command hooks — and want those to run unmodified. This Agent Note introduces the two **bridge plugins** that translate that external shell-hook protocol onto the typed extension points, built on the shared wire-protocol library ([the hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md)). -The framing that shapes the whole design: **a bridge is a compatibility adapter, not a power tool.** Anything a bridge does (block a tool, inject context, force continuation, observe a subagent) a native cordis plugin does more powerfully — typed returns, full `ctx`, no serialization boundary. The bridge's reason to exist is to run the explicitly supported subset of external CC/Codex command hooks. That keeps each bridge thin: parse the config, pick a matcher mode, build the per-event payload, call `runHook` + `mergeHookOutputs` from the shared lib, and map the neutral outcome to a typed Decision. The package READMEs own the exact current unsupported-event and partial-field inventory against the official protocols. +The core rule is: **a bridge is a compatibility adapter, not a power tool.** Anything a bridge does (block a tool, inject context, force continuation, observe a subagent) a native cordis plugin does more powerfully — typed returns, full `ctx`, no serialization boundary. The bridge's reason to exist is to run the explicitly supported subset of external CC/Codex command hooks. That keeps each bridge thin: parse the config, pick a matcher mode, build the per-event payload, call `runHook` + `mergeHookOutputs` from the shared lib, and map the neutral outcome to a typed Decision. The package READMEs own the exact current unsupported-event and partial-field inventory against the official protocols. ## Decision diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index dcc8f496cb..2df3a47fd6 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -8,7 +8,7 @@ Status: implemented harness 的扩展面是其类型化拦截点(见[拦截扩展点 Agent Note](2026-06-30-interception-extension-points.md)):所谓「原生钩子」不过是一个普通的 Cordis 插件,订阅 `agent/session-start`、`agent/pre-step`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-stopping`、`subagent/start` 或 `subagent/end`。但用户带着**既有的** Claude Code(CC)和 Codex 钩子配置到来,一个 `hooks.json`(或 settings 文件中的 `hooks` 键)里满是 shell 命令钩子,并希望它们原样运行。本 Agent Note 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化扩展点上,构建于共享的协议格式(wire format)库之上(见 [hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md))。 -贯穿整个设计的定位:**桥接是兼容性适配器,不是高级工具。** 桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能做得更强——类型化返回值、完整 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射为类型化 Decision。各包的 README 维护着当前不支持的事件和部分字段的完整清单,以官方协议为参照。 +核心规则是:**桥接是兼容性适配器,不是高级工具。** 桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能做得更强——类型化返回值、完整 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射为类型化 Decision。各包的 README 维护着当前不支持的事件和部分字段的完整清单,以官方协议为参照。 ## 决策 diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml index d01c01c55a..a67e52a0b4 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md -2026-07-16-durable-per-step-time-context.md: e1a5c65894913ad93f46db8ae45e5ef5ead215f3 -2026-07-16-durable-per-step-time-context.zh.md: 129920a02dc21a91ddc92de3d920ddad24968656 +2026-07-16-durable-per-step-time-context.md: 3305d3644fa3baf7e1522311b98b4eb29d08f631 +2026-07-16-durable-per-step-time-context.zh.md: dd7e63710ae99d1a04bc0e87d49976e28af1dae5 diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md index e1a5c65894..3305d3644f 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md @@ -6,7 +6,7 @@ English | [中文](2026-07-16-durable-per-step-time-context.zh.md) ## Problem -A request-only clock can tell the model the current time, but replacing that value in the system prompt removes the evidence behind earlier time-sensitive reasoning. Multi-step turns need requests to retain the readings that shaped preceding steps. The request must remain reconstructable after restart, and automatic compaction must account for the same timing context the model receives. +A request-only clock can tell the model the current time, but replacing that value in the system prompt removes the evidence behind earlier time-sensitive reasoning. Multi-step turns need requests to retain the readings used by preceding steps. The request must remain reconstructable after restart, and automatic compaction must account for the same timing context the model receives. A process-local refresh cache makes displayed time depend on state that cannot survive resume or be reconstructed from the durable session. Durable interval scheduling can reduce append frequency without introducing that hidden state. diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md index 129920a02d..dd7e63710a 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -仅存在于请求中的时钟可以告诉模型当前时间,但在系统提示词中替换这个值会移除先前对时间敏感的推理所依据的证据。在包含多个步骤的轮次中,请求需要保留影响先前步骤的读数。系统必须能在重启后重建请求,自动压缩(compaction)也必须将模型实际收到的同一份时间上下文纳入考量。 +仅存在于请求中的时钟可以告诉模型当前时间,但在系统提示词中替换这个值会移除先前对时间敏感的推理所依据的证据。在包含多个步骤的轮次中,请求需要保留先前步骤使用的读数。系统必须能在重启后重建请求,自动压缩(compaction)也必须将模型实际收到的同一份时间上下文纳入考量。 进程本地刷新缓存会使显示时间依赖于一种既无法在恢复后保留、也无法从持久会话重建的状态。持久的间隔调度可以减少追加频率,而不引入这种隐藏状态。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml index 6bb0f7e156..f597828cec 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-26-code-dispatch-log-spill.md -2026-07-26-code-dispatch-log-spill.md: 19d37c7ec2dbe5192e4c7e7ecd26e5b7f1467606 -2026-07-26-code-dispatch-log-spill.zh.md: 8dd270278932011db9658f9e4d5c1a1b6fe707ec +2026-07-26-code-dispatch-log-spill.md: a4b8deee86b1f6102e48e34079a93a74dfcfa288 +2026-07-26-code-dispatch-log-spill.zh.md: 0d89329dd91d74491f3f9af0812bb9b4db9c8793 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md index 19d37c7ec2..a4b8deee86 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md @@ -4,28 +4,28 @@ Status: implemented English | [中文](2026-07-26-code-dispatch-log-spill.zh.md) -> Scope: bounding the `tool/code-dispatch` event's content with the existing spill machinery. The [host foundation note](2026-07-26-code-dispatch-ui-foundation.md) accepted the unbounded log deliberately with this spill integration as the payoff point; the [live-parallel note](2026-07-26-code-mode-live-parallel-dispatch.md) settled the event pair this shaping hooks into. +> Scope: limiting the `tool/code-dispatch` event's content with the existing spill implementation. The [host foundation note](2026-07-26-code-dispatch-ui-foundation.md) deliberately accepted the unlimited log and deferred spill support to this change; the [live-parallel note](2026-07-26-code-mode-live-parallel-dispatch.md) defines the event pair that this listener processes. ## Problem -Since the full-content dispatch logging landed, a `run_code` program that reads a large file wrote the complete rendered text into the session log — uncapped and outside spill policy, while native results were bounded to `maxInlineBytes` before logging. The asymmetry was backwards: sub-calls (built for bulk data work) were precisely the calls most likely to carry huge results, and the JSONL grew by megabytes per such turn. +After full-content dispatch logging was added, a `run_code` program that reads a large file wrote the complete rendered text into the session log without a limit or spill policy, while native results were limited to `maxInlineBytes` before logging. This treated the most likely large results differently: sub-calls are intended for bulk data work, and each affected turn added megabytes to the JSONL. ## Decision -**A log-shaping waterfall on the registry, and the spill policy as its first listener.** +**A `tools/code-dispatch-log` waterfall on the registry, with spill policy as its first listener.** -- **Extension point**: `tools/code-dispatch-log` — a scope-filtered waterfall the bridge runs (via the registry's PRIVATE `shapeDispatchLog` invoker, handed to the bridge as a capability closure in `RunCodeBridgeOptions` — the waterfall is the public contract, the invoker never widens the service surface; contained: a throwing listener falls back to the unshaped content, with total error formatting so a hostile thrown value cannot escape the containment) over each settled sub-dispatch before appending `tool/code-dispatch`. The payload (`CodeDispatchLog`) carries the outer execution, the hoisted `agent` routing key, the sub-call identity, and the default content — the RENDERED result projection a native `tool/result` would carry (the program itself received the structured `value`). Only the durable copy is shapeable; the model sees neither. Shaping runs OFF the program path as tracked side work, but bounded: past `maxParallelSubCalls` pending log tasks the ordered commit lane holds, so a slow spill backend backpressures the run instead of accumulating unbounded pending I/O; run settlement still drains every task inside the open turn. -- **Policy**: `dsh-spill-policy` registers a second arm on the new extension point sharing the exact replacement pipeline of its model-facing arm (same `maxInlineBytes` cap, same preview + locator + within-cap invariant, same best-effort fallbacks), with the artifact labeled `dispatch` under the sub-call id. UIs and replay read the full text through the spill artifact exactly as they do for spilled native results, so the native-parity rendering story survives bounding. -- **One deliberate asymmetry**: the model-facing arm skips `read` (the `read → spill → read again` loop); the dispatch-log arm bounds `read` sub-calls too — a log copy is not model context, so the loop cannot happen, and `read` is precisely the tool that produces huge logs. +- **Extension point**: `tools/code-dispatch-log` is a scope-filtered waterfall that the bridge runs over each settled sub-dispatch before appending `tool/code-dispatch`. The bridge receives the registry's private `shapeDispatchLog` invoker as a capability closure in `RunCodeBridgeOptions`; the waterfall is the public contract, and the invoker does not add a service method. If a listener throws, the invoker reports any thrown value safely and uses the original settled content. The `CodeDispatchLog` payload carries the outer execution, the `agent` routing key, the sub-call identity, and the default content: the rendered result projection that a native `tool/result` would carry, while the program receives the structured `value`. A listener can replace only the durable copy, which the model never sees. The listener runs as tracked work outside the program's result path. When more than `maxParallelSubCalls` log tasks are pending, the ordered commit loop waits, so a slow spill backend limits later sub-call starts instead of accumulating unlimited pending I/O. Run settlement still waits for every task inside the open turn. +- **Policy**: `dsh-spill-policy` registers a listener for this event and uses the same replacement code as its model-result listener: the same `maxInlineBytes` limit, preview and locator, within-limit invariant, and best-effort fallback. The spill artifact is labeled `dispatch` under the sub-call id. UIs and replay read its full text through the same path used for spilled native results, so both result kinds render with the same information. +- **One deliberate difference**: the model-result listener skips `read` to prevent a `read → spill → read again` loop. The dispatch-log listener also replaces oversized `read` sub-call content because a log copy is not model context, so that loop cannot occur, and `read` is the tool most likely to produce a large log entry. ## Alternatives considered -**Bound inside the bridge with a plain cap (no spill).** Rejected: truncation without a locator loses data replay/UIs may need, and re-introduces the "truncated summary" degraded render path the stack removed. +**Apply a plain byte limit inside the bridge without spill storage.** Rejected: truncation without a locator loses data that replay or UIs may need and restores the less informative "truncated summary" rendering that earlier changes removed. -**Spill inside the bridge directly (call `ctx.spillStore` from code-mode.ts).** Rejected: the registry would grow a hard dependency on the spill capability; the waterfall keeps the policy where every other spill decision lives, composable and disable-able (omitted `maxInlineBytes` still means a true no-op). +**Spill inside the bridge directly by calling `ctx.spillStore` from `code-mode.ts`.** Rejected: the registry would require the spill capability. The waterfall keeps this policy with the other spill decisions and allows compositions to omit it; omitting `maxInlineBytes` still makes the listener a no-op. -**Reuse `tools/post-execute` for nested calls instead of a new event.** Rejected: post-execute shapes the PROGRAM-facing result (nested calls deliberately skip it so programs get complete data); the durable copy needs its own decision point after the program has its value. +**Reuse `tools/post-execute` for nested calls instead of a new event.** Rejected: post-execute can change the program-facing result, so nested calls deliberately skip it and programs receive complete data. The durable copy needs a separate listener that runs after the program has its value. ## Consequences -The session log is bounded again for Code Mode turns — the README's Known Limitations entry about uncapped dispatch logging is resolved and now points here. Old logs with oversized dispatch content still replay (the event shape is unchanged; only future appends shrink). The web UI renders spilled sub-call output as the preview + locator text through the identical native path, no special casing. +Code Mode dispatch entries in the session log now have the configured byte limit, and the README's Known Limitations entry about unlimited dispatch logging now points here. Old logs with oversized dispatch content still replay because the event fields are unchanged; only future appends contain less text. The web UI renders spilled sub-call output as preview and locator text through the same path as native results, with no special case. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md index 8dd2702789..0d89329dd9 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md @@ -4,28 +4,28 @@ Status: implemented [English](2026-07-26-code-dispatch-log-spill.md) | 中文 -> 范围:用既有的 spill 机制为 `tool/code-dispatch` 事件的内容施加边界。[宿主侧基础 Agent Note](2026-07-26-code-dispatch-ui-foundation.md)当初有意接受了不设上限的日志,并以这次 spill 集成为兑现点;[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md)敲定了本次整形所挂接的事件对。 +> 范围:用既有的 spill 实现限制 `tool/code-dispatch` 事件的内容。[宿主侧基础 Agent Note](2026-07-26-code-dispatch-ui-foundation.md)有意接受了不设上限的日志,并把 spill 支持留到本次更改;[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md)定义了该监听器处理的事件对。 ## 问题 -自携带完整内容的分发日志落地以来,读取大文件的 `run_code` 程序过去会把完整的渲染文本写进会话日志,不设上限、位于 spill 策略之外;而原生结果在记录之前就已被限制在 `maxInlineBytes` 以内。这种不对称的方向完全反了:子调用(本就为批量数据工作而设计)恰恰是最可能携带巨大结果的调用,而每个这样的轮次都会让 JSONL 增长数 MB。 +加入完整内容的分发日志后,读取大文件的 `run_code` 程序会把完整的渲染文本写进会话日志,既没有上限,也不经过 spill 策略;原生结果则会在记录之前限制在 `maxInlineBytes` 以内。两类结果受到不同处理,而为批量数据工作设计的子调用最可能产生巨大结果;每个受影响的轮次都会让 JSONL 增长数 MB。 ## 决策 -**在注册表上增设一个日志整形 waterfall(瀑布式事件),spill 策略作为其第一个监听器。** +**在注册表上增设 `tools/code-dispatch-log` waterfall(瀑布式事件),spill 策略作为其第一个监听器。** -- **扩展点**:`tools/code-dispatch-log`,一个按作用域过滤的 waterfall,由桥接层在追加 `tool/code-dispatch` 之前对每个已结算的子分发运行(经由注册表的私有 `shapeDispatchLog` 调用器——作为能力闭包经 `RunCodeBridgeOptions` 交给桥接层;waterfall 才是公开约定,调用器绝不扩大服务接口。故障被兜住:监听器抛出异常时回退到未整形的内容,并用可处理任意抛出值的错误格式化,确保恶意抛出值无法逃出兜底)。载荷(`CodeDispatchLog`)携带外层执行、提升出来的 `agent` 路由键、子调用标识与默认内容——即原生 `tool/result` 所载的渲染后结果投影(程序本身收到的是结构化 `value`)。可整形的只有持久副本;模型两者都看不到。整形作为被跟踪的旁路工作在程序路径之外运行,但有界:待处理日志任务超过 `maxParallelSubCalls` 时有序提交通道会暂停,因此慢速 spill 后端会对整个 run 施加背压,而不是无限累积待完成 I/O;run 结算仍会在开放轮次内排空全部任务。 -- **策略**:`dsh-spill-policy` 在新扩展点上注册第二个分支,与其面向模型的分支共用一模一样的替换流水线(同样的 `maxInlineBytes` 上限、同样的预览 + 定位符 + 不超上限不变式、同样的尽力而为回退),产物以 `dispatch` 为标签,记在子调用 id 名下。UI 与回放通过 spill 产物读取全文,方式与读取被 spill 的原生结果完全相同,因此与原生同等保真的渲染在施加边界之后依然成立。 -- **一处有意的不对称**:面向模型的分支跳过 `read`(避免 `read → spill → read again` 循环);分发日志分支则连 `read` 子调用也施加边界:日志副本不是模型上下文,该循环因此不可能发生,而 `read` 恰恰是会产生巨大日志的那个工具。 +- **扩展点**:`tools/code-dispatch-log` 是一个按作用域过滤的 waterfall,桥接层会在追加 `tool/code-dispatch` 之前,对每个已结算的子分发运行它。桥接层通过 `RunCodeBridgeOptions` 接收注册表私有的 `shapeDispatchLog` 调用器;waterfall 是公开约定,该调用器不会增加服务方法。监听器抛出异常时,调用器会安全地报告任意抛出值,并使用原始的已结算内容。`CodeDispatchLog` 载荷包含外层执行、`agent` 路由键、子调用标识和默认内容;默认内容是原生 `tool/result` 会携带的渲染后结果投影,而程序收到结构化 `value`。监听器只能替换持久化副本,模型不会看到这份副本。监听器作为受跟踪任务在程序的返回路径之外运行。待处理日志任务超过 `maxParallelSubCalls` 时,有序提交循环会等待,因此慢速 spill 后端会限制后续子调用启动,而不会无限累积待完成 I/O。run 结算仍会在开放轮次内等待全部任务完成。 +- **策略**:`dsh-spill-policy` 为该事件注册监听器,并复用面向模型结果的监听器所用的替换代码:相同的 `maxInlineBytes` 上限、预览和定位符、不超上限不变式,以及尽力而为回退。spill 产物以 `dispatch` 为标签,记录在子调用 id 名下。UI 与回放通过读取被 spill 原生结果的同一路径读取全文,因此两类结果会渲染出相同的信息。 +- **一处有意差异**:面向模型结果的监听器跳过 `read`,以防出现 `read → spill → read again` 循环。分发日志监听器也会替换过大的 `read` 子调用内容,因为日志副本不是模型上下文,该循环不会发生,而 `read` 最可能产生巨大的日志条目。 ## 曾考虑的替代方案 -**在桥接层内部用普通上限施加边界(不做 spill)。** 否决:没有定位符的截断会丢失回放与 UI 可能需要的数据,还会重新引入本堆叠 PR(Pull Request)链已经移除的「截断摘要」降级渲染路径。 +**在桥接层内部使用普通字节数上限,不存入 spill。** 否决:没有定位符的截断会丢失回放或 UI 可能需要的数据,还会恢复之前更改已经移除的、信息较少的「截断摘要」渲染。 -**直接在桥接层内做 spill(从 code-mode.ts 调用 `ctx.spillStore`)。** 否决:注册表会因此对 spill 能力产生硬依赖;waterfall 则把策略留在所有其他 spill 决策所在的地方,既可组合也可禁用(省略 `maxInlineBytes` 依然意味着真正的 no-op)。 +**直接在桥接层内做 spill,即从 `code-mode.ts` 调用 `ctx.spillStore`。** 否决:注册表会要求提供 spill 能力。waterfall 把该策略与其他 spill 决策放在一起,并允许组合不加载它;省略 `maxInlineBytes` 时,该监听器仍不执行任何操作。 -**让嵌套调用复用 `tools/post-execute`,而不是新增一个事件。** 否决:post-execute 整形的是面向程序的那份结果(嵌套调用有意跳过它,好让程序拿到完整数据);持久副本需要一个属于自己的决策点,位于程序取得其值之后。 +**让嵌套调用复用 `tools/post-execute`,而不是新增一个事件。** 否决:post-execute 可以修改面向程序的结果,因此嵌套调用有意跳过它,让程序取得完整数据。持久化副本需要一个单独的监听器,在程序取得其值之后运行。 ## 后果 -对 Code Mode 轮次而言,会话日志重新有了边界:README 中关于分发日志不设上限的 「已知限制」条目已经解决,现在指向本篇。携带超大分发内容的旧日志仍可回放(事件形状未变;只有今后的追加才会变小)。Web UI 经由与原生完全相同的路径,把被 spill 的子调用输出渲染为预览 + 定位符文本,没有任何特殊处理。 +会话日志中的 Code Mode 分发条目现在遵守已配置的字节数上限,README 中关于分发日志不设上限的「已知限制」条目现在指向本篇。携带超大分发内容的旧日志仍可回放,因为事件字段没有变化;只有今后的追加包含更少文本。Web UI 经由与原生结果相同的路径,把被 spill 的子调用输出渲染为预览和定位符文本,不需要特殊处理。 diff --git a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.i18n.yaml index 0e4f334485..30111dab4b 100644 --- a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.md -2026-07-30-plan-review-presentation-intent.md: f524f7ec67dff3e6d7a6bf59ac204f5c8aae13c6 -2026-07-30-plan-review-presentation-intent.zh.md: 69e0ae00fbc2a98ed45a6d2b711a17f7c947c7e6 +2026-07-30-plan-review-presentation-intent.md: 62e1c24aba7a2901a46da4e6ace9707814472974 +2026-07-30-plan-review-presentation-intent.zh.md: 8415b94cab697e0fa9c4094b938d8ceb99107c7f diff --git a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.md b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.md index f524f7ec67..62e1c24aba 100644 --- a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.md +++ b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.md @@ -12,15 +12,15 @@ Every one of those affordances is wrong for the surface. Reviewing a plan is one ## Decision -A question may declare a **presentation intent**, and the Web composer renders a declared intent as its own surface. `AskUserQuestionItem` gains `intent?: AskUserQuestionIntent`, a tagged shape whose one member is `{ kind: 'plan-review', approve: string }`; `plan-mode` sets it on the review question, naming `Approve` as the label that approves. +A question may declare a **presentation intent**, and the Web composer renders a declared intent as its own surface. `AskUserQuestionItem` gains `intent?: AskUserQuestionIntent`, a tagged union whose one member is `{ kind: 'plan-review', approve: string }`; `plan-mode` sets it on the review question, naming `Approve` as the label that approves. -An intent shapes presentation only. The answer protocol is untouched: a UI honouring the intent answers with the same option labels a generic UI would send, so `exit_plan_mode` reads one answer shape regardless of which surface collected it, and a UI that does not know a tag renders the generic flow with nothing lost but the layout. +An intent changes presentation only. The answer protocol is untouched: a UI honouring the intent answers with the same option labels a generic UI would send, so `exit_plan_mode` reads the same answer fields regardless of which surface collected them, and a UI that does not know a tag renders the generic flow with nothing lost but the layout. `approve` names the affirmative option instead of relying on option order, so no UI infers a verdict from a position. Two assertions an intent makes are beyond the types, and `UserInteractionService.ask()` rejects both as `BAD_INTENT` at the asker: an `approve` naming none of that question's own options — before any UI can answer a choice never offered — and an intent on a question with no `detail`, the thing it declares itself a review of, which would ask the user to approve something invisible. On the wire the intent is a discriminated union, so an unrecognised tag is a rejected frame rather than a silently generic render. `ui-question` renders the intent as `PlanReviewPanel`, in the waiting-approval card language: the amber strip carries `Plan review`, the plan is the scrolling markdown body, and the decision row holds three actions — `Chat about it`, `Refuse`, `Approve`. The question text becomes the card's accessible name rather than a headline, because the buttons already say what the decision is. Approve and Refuse answer with the asker's own option labels and keep the asker's descriptions as tooltips; `Chat about it` cancels the request, which returns the composer so the user can simply say what they want. All copy is bilingual under the existing `question` namespace. -Routing lives inside the single composer entry (`QuestionComposer` chooses the shape) rather than in a second chain registration, and `planReviewOf` claims a request only when the card can send every answer that request allows: one question declaring the intent, the plan as its `detail`, the named approve label offered, and a binary single choice — at most one option besides approve, and not multi-select. A third option or a multi-select batch has answers two buttons cannot express, so the generic flow keeps it, and keeps anything else the card cannot render. "Presentation only" is therefore literal: an intent never costs the user a reachable answer, and the client — downstream of a wire boundary — leaves every request answerable. +Routing lives inside the single composer entry (`QuestionComposer` chooses the presentation) rather than in a second chain registration, and `planReviewOf` claims a request only when the card can send every answer that request allows: one question declaring the intent, the plan as its `detail`, the named approve label offered, and a binary single choice — at most one option besides approve, and not multi-select. A third option or a multi-select batch has answers two buttons cannot express, so the generic flow keeps it, and keeps anything else the card cannot render. "Presentation only" is therefore literal: an intent never costs the user a reachable answer, and the client — downstream of a wire boundary — leaves every request answerable. Dismissal became its own model-facing outcome. `ASK_CANCELLED` previously reached the model as "the user cancelled ask_user_question", naming a tool it never called; `exit_plan_mode` now reports that the user dismissed the review to speak instead and to stay in plan mode and wait. Every other ask failure — an abort from turn cancel or provider teardown, where no user is coming — keeps its own message. diff --git a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.zh.md b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.zh.md index 69e0ae00fb..8415b94cab 100644 --- a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.zh.md @@ -12,15 +12,15 @@ Status: implemented ## 决定 -一个问题可以声明**呈现意图(presentation intent)**,Web 输入区把已声明的意图渲染为它自己的界面。`AskUserQuestionItem` 新增 `intent?: AskUserQuestionIntent`,一个带标签的形状,目前唯一成员是 `{ kind: 'plan-review', approve: string }`;`plan-mode` 在审阅问题上设置它,并指明 `Approve` 是表示批准的标签。 +一个问题可以声明**呈现意图(presentation intent)**,Web 输入区把已声明的意图渲染为它自己的界面。`AskUserQuestionItem` 新增 `intent?: AskUserQuestionIntent`,这是一个带标签的联合,目前唯一成员是 `{ kind: 'plan-review', approve: string }`;`plan-mode` 在审阅问题上设置它,并指明 `Approve` 是表示批准的标签。 -意图只塑造呈现。回答协议不变:遵循意图的 UI 回答的仍是通用 UI 会发送的那些选项标签,因此无论由哪个界面收集,`exit_plan_mode` 读到的都是同一种回答形状;而不认识某个标签的 UI 渲染通用流程,除布局之外一无所失。 +意图只改变呈现。回答协议不变:遵循意图的 UI 回答的仍是通用 UI 会发送的那些选项标签,因此无论由哪个界面收集,`exit_plan_mode` 读到的都是同一组回答字段;而不认识某个标签的 UI 渲染通用流程,除布局之外一无所失。 `approve` 指名肯定选项,而不依赖选项顺序,因此没有任何 UI 会从位置推断裁决。意图作出的两项断言超出类型的表达能力,`UserInteractionService.ask()` 都以 `BAD_INTENT` 在提问方一侧拒绝:`approve` 未命中该问题自身的任一选项 —— 早于任何 UI 回答一个从未被提供过的选择;以及意图落在没有 `detail` 的问题上,而 `detail` 正是它自称在审阅的东西,那会让用户去批准一件看不见的事。在协议格式(wire format)上意图是可辨识联合,因此无法识别的标签是被拒绝的帧,而不是静默退回通用渲染。 `ui-question` 把该意图渲染为 `PlanReviewPanel`,沿用等待审批卡片的语言:琥珀色条带写着 `Plan review`,计划是可滚动的 markdown 主体,决定行放三个操作 —— `Chat about it`、`Refuse`、`Approve`。问题文本成为卡片的无障碍名称而非标题,因为按钮已经说明了这次决定是什么。Approve 与 Refuse 用提问方自己的选项标签回答,并把提问方的描述保留为 tooltip;`Chat about it` 取消该请求,从而让输入区归位,用户直接说他想说的话即可。所有文案在既有 `question` 命名空间下双语。 -路由住在单一输入区条目内部(由 `QuestionComposer` 选择形状),而不是第二个链式注册;`planReviewOf` 仅在卡片能够发出该请求允许的每一个答案时才接管:只有一个问题且声明了意图、以 `detail` 承载计划、提供了被指名的批准标签,且是二元单选 —— 除批准外最多一个选项,且非多选。出现第三个选项或多选批次时,其答案是两个按钮无法表达的,通用流程保留它,也保留其他任何卡片渲染不了的请求。因此"只塑造呈现"是字面意义上的:意图绝不让用户失去一个可达的答案,而位于协议边界下游的客户端让每个请求都保持可回答。 +路由住在单一输入区条目内部(由 `QuestionComposer` 选择呈现),而不是第二个链式注册;`planReviewOf` 仅在卡片能够发出该请求允许的每一个答案时才接管:只有一个问题且声明了意图、以 `detail` 承载计划、提供了被指名的批准标签,且是二元单选 —— 除批准外最多一个选项,且非多选。出现第三个选项或多选批次时,其答案是两个按钮无法表达的,通用流程保留它,也保留其他任何卡片渲染不了的请求。因此"只改变呈现"是字面意义上的:意图绝不让用户失去一个可达的答案,而位于协议边界下游的客户端让每个请求都保持可回答。 放弃审阅成为面向模型的独立结果。`ASK_CANCELLED` 以前传到模型的是"the user cancelled ask_user_question",指名了一个它从未调用的工具;现在 `exit_plan_mode` 报告用户放弃审阅是为了改用说话,并要求留在 plan mode 中等待。其余每一种 ask 失败 —— 轮次取消或提供方拆卸导致的中止,那里并没有用户会来 —— 保留它们自己的消息。 diff --git a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml index b768fb0abc..1e3db15084 100644 --- a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md -2026-07-23-translation-prompt-v4-contract.md: adeb63e0f4bf03ec0b94a371f596bb84138d81ed -2026-07-23-translation-prompt-v4-contract.zh.md: bd9a18788cbc04f733f52270ec486923c552459e +2026-07-23-translation-prompt-v4-contract.md: 68d6837971a0bbb40fbc5ee093e5911515905ba3 +2026-07-23-translation-prompt-v4-contract.zh.md: d5370f5d6d62a6ca89187d9911fc4dd5c5758b07 diff --git a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md index adeb63e0f4..68d6837971 100644 --- a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md +++ b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md @@ -10,7 +10,7 @@ Automated counterpart generation needs a stable prompt that reproduces the regis ## Decision -The committed [translation prompt](../../../../docs/i18n/translation-prompt.md) is the calibrated pipeline asset. Its renderer injects only the source language, target language, and current [terminology table](../../../../docs/i18n/terminology.md), and rejects unknown, missing, or malformed placeholder syntax before assembling a request. The request assembler retains the source basename outside the model-visible prompt and places each reviewed whole-document pair into one bare-text user/assistant example turn before the real source document. The template may carry model-specific calibration rules, but those rules remain subordinate to the repository's binding pairing, terminology, structure, and emphasis contracts. +The committed [translation prompt](../../../../docs/i18n/translation-prompt.md) is the calibrated pipeline asset. Its renderer injects only the source language, target language, and current [terminology table](../../../../docs/i18n/terminology.md), and rejects unknown, missing, or malformed placeholder syntax before assembling a request. The request assembler retains the source basename outside the model-visible prompt and places each reviewed whole-document pair into one bare-text user/assistant example turn before the real source document. The template may carry model-specific calibration rules, but those rules remain subordinate to the repository's pairing, terminology, structure, and emphasis contracts. The v7 calibration retains that v4 protocol and makes the instruction priority explicit: source meaning and protected structure, then the terminology table, then whole-document gold-pair voice, then general guidance and embedded examples. It directs the model to draft as a native technical author and then compare clause by clause, preserving actors, conditions, negation, modality, lifecycle conditions, direction, result channels, ownership, and quantities. Style guidance cannot invent an actor or vary a terminology-table form, defined concept, or contract verb merely for variety. Unresolved terminology stays unchanged in the translation and is reported only as pending review. @@ -26,7 +26,7 @@ The executable contract lives in [the renderer, request assembler, parser, and r **Inject `translation-rules.md` into every request.** That document governs humans and agents as well as the automated pipeline. Injecting it couples each editorial clarification to model behavior and displaces the manually calibrated prompt constraints; the pipeline instead injects the binding terminology table and verifies its own asset directly. -**Use a strict CDATA XML document.** CDATA provides general XML framing but adds a nested protocol, an additional `]]>` escape, and XML-parser behavior that the three-section contract does not otherwise need. Reserving and escaping six delimiter lines keeps the calibrated response shape while preserving arbitrary Markdown. +**Use a strict CDATA XML document.** CDATA provides general XML framing but adds a nested protocol, an additional `]]>` escape, and XML-parser behavior that the three-section contract does not otherwise need. Reserving and escaping six delimiter lines keeps the calibrated response sections while preserving arbitrary Markdown. **Return only the final translation.** A single body is simpler to parse but discards the explicit correction pass used to catch tone, structure, terminology, and punctuation defects before publication. @@ -36,4 +36,4 @@ The executable contract lives in [the renderer, request assembler, parser, and r ## Consequences -Prompt wording is executable behavior and receives code review, a translation-prompt verifier, and a runnable request/response snapshot. Focused tests pin the retained v4 examples and selected v7 safeguards. The `translation-prompt-v4` snapshot directory names the stable renderer/parser protocol lineage rather than the current calibration revision. The calibrated asset and the general translation rules can evolve for their different audiences, but review must reject contradictions with binding repository contracts. The line escape is visible only when source documentation contains a wrapper tag on its own line, and parser tests pin its lossless behavior. +Prompt wording is executable behavior and receives code review, a translation-prompt verifier, and a runnable request/response snapshot. Focused tests pin the embedded examples and selected v7 safeguards. The `translation-prompt-v4` snapshot directory names the stable renderer/parser protocol series rather than the current calibration revision. The calibrated asset and the general translation rules can evolve for their different audiences, but review must reject contradictions with repository contracts. The line escape is visible only when source documentation contains a wrapper tag on its own line, and parser tests pin its lossless behavior. diff --git a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md index bd9a18788c..d5370f5d6d 100644 --- a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -提交入库的[翻译提示词](../../../../docs/i18n/translation-prompt.md)是经过校准的流水线资源。其渲染器仅注入源语言、目标语言和当前[术语表](../../../../docs/i18n/terminology.md),并在组装请求前拒绝未知、缺失或语法格式错误的占位符。请求组装器在模型可见的提示词之外保留源文件基本名,并在真正的源文档之前,将每组经评审的整篇文档对编排为一个纯文本 user/assistant 示例轮次。模板可以包含针对特定模型的校准规则,但这些规则必须服从仓库中具约束力的配对、术语、结构与强调格式约定。 +提交入库的[翻译提示词](../../../../docs/i18n/translation-prompt.md)是经过校准的流水线资源。其渲染器仅注入源语言、目标语言和当前[术语表](../../../../docs/i18n/terminology.md),并在组装请求前拒绝未知、缺失或语法格式错误的占位符。请求组装器在模型可见的提示词之外保留源文件基本名,并在真正的源文档之前,将每组经评审的整篇文档对编排为一个纯文本 user/assistant 示例轮次。模板可以包含针对特定模型的校准规则,但这些规则必须服从仓库的配对、术语、结构与强调格式约定。 v7 校准保留这套 v4 协议,并明确指令优先级:先保持源文含义与受保护结构,再遵循术语表,然后以整篇金标校准语体,最后应用一般指导与内嵌示例。模型先以母语技术作者的方式起草,再逐项对照源文,保留执行主体、条件、否定、情态、生命周期条件、方向、结果通道、所有权和数量。文体指导不得虚构执行主体,也不得仅为丰富措辞而改换术语表词形、已定义概念或约定动词。无法裁定的术语在译文中保持不变,只在评审段标为待人工确认。 @@ -26,7 +26,7 @@ v7 校准保留这套 v4 协议,并明确指令优先级:先保持源文含 **在每个请求中注入 `translation-rules.md`。** 该文档既约束人类与 agent,也约束自动翻译流水线。注入它会让编辑规范的每次澄清都与模型行为耦合,并挤占经过人工校准的提示词约束;因此流水线仅注入具约束力的术语表,并直接校验自身资源。 -**使用严格的 CDATA XML 文档。** CDATA 提供通用的 XML 封装,但会引入一层嵌套协议、额外的 `]]>` 转义规则,以及三段式约定原本不需要的 XML 解析器行为。预留并转义六种定界行,既能维持经校准的响应形态,也能保留任意 Markdown 内容不变。 +**使用严格的 CDATA XML 文档。** CDATA 提供通用的 XML 封装,但会引入一层嵌套协议、额外的 `]]>` 转义规则,以及三段式约定原本不需要的 XML 解析器行为。预留并转义六种定界行,既能维持经校准的响应分段,也能保留任意 Markdown 内容不变。 **只返回最终译文。** 单一正文更易解析,却会丢弃显式修正步骤;这个步骤用于在发布前发现语气、结构、术语和标点缺陷。 @@ -36,4 +36,4 @@ v7 校准保留这套 v4 协议,并明确指令优先级:先保持源文含 ## 影响 -提示词措辞属于可执行行为,因此需要经过代码评审、翻译提示词校验器校验及可运行的请求/响应快照验证。聚焦测试固定保留的 v4 示例与选定的 v7 保护规则。`translation-prompt-v4` 快照目录命名的是稳定的渲染器/解析器协议谱系,而不是当前校准修订号。经校准的资源与通用翻译规则可以针对各自的受众分别演进,但评审必须拒绝任何与仓库约束性约定冲突的改动。只有当源文档中的封装标签独占一行时,行转义才会显现;解析器测试锁定这一无损行为。 +提示词措辞属于可执行行为,因此需要经过代码评审、翻译提示词校验器校验及可运行的请求/响应快照验证。聚焦测试固定内嵌示例与选定的 v7 保护规则。`translation-prompt-v4` 快照目录命名的是稳定的渲染器/解析器协议系列,而不是当前校准修订号。经校准的资源与通用翻译规则可以针对各自的受众分别演进,但评审必须拒绝任何与仓库约定冲突的改动。只有当源文档中的封装标签独占一行时,行转义才会显现;解析器测试锁定这一无损行为。 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index 72574c7cf5..1c58887b2b 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: 531b780974e1490b27d8b96734ccfadae66c2fc2 -2026-07-31-installer-adopts-existing-checkout.zh.md: 405b58d8d53b3f0895cc7746c957582455667cda +2026-07-31-installer-adopts-existing-checkout.md: 1d45a14273095610e3ed8047da2ce9f4ca95cbb6 +2026-07-31-installer-adopts-existing-checkout.zh.md: 971bc3b389c341b314872b8e45ab20ebd2ed5b2c diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index 531b780974..1d45a14273 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -6,9 +6,9 @@ English | [中文](2026-07-31-installer-adopts-existing-checkout.zh.md) ## Problem -`scripts/install.sh` produced two incompatible install shapes. A `curl … | sh` install built the managed layout — a master clone at `~/.dsh/source/master`, a staging worktree on `dsh-staging/<timestamp>`, and the stable `current` symlink the PATH launcher resolves through. Running the same script from a checkout instead linked `dsh` straight at that checkout's `bin/dsh`, per the earlier [in-repo skip-clone decision](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md). +`scripts/install.sh` produced two incompatible installation layouts. A `curl … | sh` install built the managed layout — a master clone at `~/.dsh/source/master`, a staging worktree on `dsh-staging/<timestamp>`, and the stable `current` symlink the PATH launcher resolves through. Running the same script from a checkout instead linked `dsh` straight at that checkout's `bin/dsh`, per the earlier [in-repo skip-clone decision](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md). -The direct link is a terminal state. `current` is what an upgrade repoints, so an install without it is not upgradable by [`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md); the PATH symlink dangles if the checkout moves; and the launcher resolves to whatever branch the contributor happened to have checked out, which the upgrade contract forbids as a launcher target. The upgrade skill already described this shape as a legacy install needing a one-time migration, so the layouts diverged at install time and were reconciled only later, if ever. +The direct link cannot be upgraded. `current` is what an upgrade repoints, so an install without it is not upgradable by [`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md); the PATH symlink dangles if the checkout moves; and the launcher resolves to whatever branch the contributor happened to have checked out, which the upgrade contract forbids as a launcher target. The upgrade skill already described this layout as a legacy install needing a one-time migration, so the layouts diverged at install time and were reconciled only later, if ever. ## Decision @@ -28,9 +28,9 @@ Before `current` is repointed, the installer rejects a staging path that resolve **Make `~/.dsh/source/master` a symlink to the arbitrary clone.** Rejected. Git resolves the symlink and records the *real* path: a worktree created through it stores `gitdir: …/<clone>/.git/worktrees/<name>`, and `git worktree list` reports the clone. The symlink is therefore decorative — nothing reads it — while implying the container owns the repository. It also fails silently: moving the clone leaves `master` present but dangling and every staging worktree dead with `fatal: not a git repository`. Worst, it aliases two names onto one tree, so the "current must never be the master clone" check passes by string comparison while being false. `~/.dsh/source/master` is a location, not a name, and only the location is authoritative. -**Promote the checkout itself to the `current` target.** Rejected: the upgrade contract requires `current` to be a clean staging worktree on a staging branch, never a feature, review, or detached checkout. It would also make every upgrade rewrite the tree the contributor is editing. +**Promote the checkout itself to the `current` target.** Rejected: the upgrade contract requires `current` to point to a clean staging worktree on a staging branch, never a feature, review, or detached checkout. It would also make every upgrade rewrite the tree the contributor is editing. -**Keep link-in-place behind a prompt or a `DSH_ADOPT` flag.** Rejected, and an earlier revision of this change shipped exactly that before it was removed. The divergent shape was the defect itself, so retaining it as an option preserves the problem and doubles the states every later change must reason about — the prompt, the flag, the dirty-tree warning, and a second linking path all existed only to keep a shape nothing should produce. The original motivation for link-in-place, keeping the script testable against local source, survives adoption: a staging worktree branched from the checkout's `HEAD` runs the same code. `DSH_SOURCE` remains the escape hatch for installing a separate tree. +**Keep link-in-place behind a prompt or a `DSH_ADOPT` flag.** Rejected, and an earlier revision of this change shipped exactly that before it was removed. The second layout was the defect itself, so retaining it as an option preserves the problem and doubles the states every later change must handle — the prompt, the flag, the dirty-tree warning, and a second linking path all existed only to keep a layout nothing should produce. The original motivation for link-in-place, keeping the script testable against local source, survives adoption: a staging worktree branched from the checkout's `HEAD` runs the same code. `DSH_SOURCE` remains available for installing a separate tree. **Warn or prompt when the tree is dirty.** Rejected: `worktree add` from `HEAD` cannot carry uncommitted work, so the behavior is determined and a prompt only adds a decision the user cannot act on differently. The contract is documented instead. @@ -38,7 +38,7 @@ Before `current` is repointed, the installer rejects a staging path that resolve ## Consequences -One layout now serves every install, so an adopted clone is upgradable by `dsh-upgrade` without the one-time migration that skill described, and the installer has no branch that produces an unupgradable shape. In-repo runs still never mutate the working tree. +One layout now serves every install, so an adopted clone is upgradable by `dsh-upgrade` without the one-time migration that skill described, and the installer has no branch that produces an unupgradable layout. In-repo runs still never mutate the working tree. The cost is that a contributor can no longer point PATH at a checkout and have `dsh` follow that working tree as they switch branches: the launcher now resolves to a staging worktree pinned to the `HEAD` adopted at install time. Re-running the installer adopts the current `HEAD` again. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index 405b58d8d5..971bc3b389 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -6,9 +6,9 @@ Status: implemented ## Problem -`scripts/install.sh`会产生两种互不兼容的安装形态。`curl … | sh`安装会构建受管布局——`~/.dsh/source/master`处的 master 克隆、位于`dsh-staging/<时间戳>`分支上的 staging worktree,以及 PATH 启动器据以解析的稳定`current`符号链接。而从检出中运行同一脚本时,则依据此前的[检出内跳过克隆决策](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md),把`dsh`直接链接到该检出的`bin/dsh`。 +`scripts/install.sh`会产生两种互不兼容的安装布局。`curl … | sh`安装会构建受管布局——`~/.dsh/source/master`处的 master 克隆、位于`dsh-staging/<时间戳>`分支上的 staging worktree,以及 PATH 启动器据以解析的稳定`current`符号链接。而从检出中运行同一脚本时,则依据此前的[检出内跳过克隆决策](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md),把`dsh`直接链接到该检出的`bin/dsh`。 -这种直接链接是一种终态。升级重指的正是`current`,因此缺少它的安装无法通过[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md)升级;检出一旦移动,PATH 符号链接就会失效;而且启动器会解析到贡献者恰好检出的任意分支,这正是升级约定禁止作为启动器目标的情形。升级技能早已把这种形态描述为需要一次性迁移的旧式安装,于是两种布局在安装时就已分叉,并且要到很久以后才会被调和——甚至永远不会。 +这种直接链接无法升级。升级重指的正是`current`,因此缺少它的安装无法通过[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md)升级;检出一旦移动,PATH 符号链接就会失效;而且启动器会解析到贡献者恰好检出的任意分支,这正是升级约定禁止作为启动器目标的情形。升级技能早已把这种布局描述为需要一次性迁移的旧式安装,因此两种布局从安装时起便不相同,只有以后执行迁移才会一致,而迁移也可能永远不执行。 ## Decision @@ -28,9 +28,9 @@ Status: implemented **把`~/.dsh/source/master`做成指向该任意克隆的符号链接。** 已否决。Git 会解析该符号链接并记录*真实*路径:经由它创建的 worktree 会存储`gitdir: …/<克隆>/.git/worktrees/<名称>`,而`git worktree list`报告的是该克隆。因此这个符号链接纯属装饰——没有任何代码读取它——却又暗示容器拥有该仓库。它还会静默失效:移动克隆后,`master`看似仍在却已悬空,而每个 staging worktree 都会以`fatal: not a git repository`失败。最糟的是,它把两个名称别名到同一棵树上,于是"current 绝不能是 master 克隆"这项检查会在字符串比较下通过,实则为假。`~/.dsh/source/master`是位置而非名称,且只有位置具有权威性。 -**把检出自身提升为`current`的目标。** 已否决:升级约定要求`current`必须是位于 staging 分支上的干净 staging worktree,绝不能是 feature、review 或 detached 检出。这还会使每次升级都改写贡献者正在编辑的那棵树。 +**把检出自身提升为`current`的目标。** 已否决:升级约定要求`current`指向 staging 分支上的干净 staging worktree,绝不能指向 feature、review 或 detached 检出。这还会使每次升级都改写贡献者正在编辑的那棵树。 -**把就地链接保留在提示或`DSH_ADOPT`开关之后。** 已否决;本次变更的早期修订版本正是如此实现,之后被移除。分叉的形态本身就是缺陷,因此把它保留为一个选项等于保留了问题,并使此后每次改动需要推敲的状态翻倍——提示、开关、工作树不干净的警告,以及第二条链接路径,全都只为维持一种本不该产生的形态而存在。就地链接最初的动机——让脚本能针对本地源码进行测试——在接管方案下依然成立:以检出的`HEAD`为起点创建的 staging worktree 运行的是同一份代码。`DSH_SOURCE`仍是安装另一棵树的退路。 +**把就地链接保留在提示或`DSH_ADOPT`开关之后。** 已否决;本次变更的早期修订版本正是如此实现,之后被移除。第二种布局本身就是缺陷,因此把它保留为一个选项等于保留了问题,并使此后每次改动必须处理的状态翻倍——提示、开关、工作树不干净的警告,以及第二条链接路径,全都只为维持一种本不该产生的布局而存在。就地链接最初的动机——让脚本能针对本地源码进行测试——在接管方案下依然成立:以检出的`HEAD`为起点创建的 staging worktree 运行的是同一份代码。`DSH_SOURCE`仍可用于安装另一棵树。 **在工作树不干净时发出警告或提示。** 已否决:以`HEAD`为起点的`worktree add`本就无法带上未提交的内容,因此该行为是确定的,提示只会增加一个用户无法做出不同选择的决策点。改为在文档中说明该约定。 @@ -38,7 +38,7 @@ Status: implemented ## Consequences -现在一套布局服务于所有安装,因此被接管的克隆无需该技能所述的一次性迁移,即可由`dsh-upgrade`升级,而且安装器不再有任何一条分支会产生无法升级的形态。检出内运行仍然绝不改动工作树。 +现在一套布局服务于所有安装,因此被接管的克隆无需该技能所述的一次性迁移,即可由`dsh-upgrade`升级,而且安装器不再有任何一条分支会产生无法升级的布局。检出内运行仍然绝不改动工作树。 代价是:贡献者不能再把 PATH 指向某个检出、并让`dsh`随其切换分支而跟随该工作树;启动器现在解析到的是一个固定在安装时所接管`HEAD`上的 staging worktree。重新运行安装器会再次接管当前的`HEAD`。 diff --git a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml index 295dcfd0bf..1c22b0d7f6 100644 --- a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-unified-github-label-taxonomy.md -2026-08-08-unified-github-label-taxonomy.md: 44508b4f0e2dc4de97950b0738829c0b362cc506 -2026-08-08-unified-github-label-taxonomy.zh.md: b1e3629fd93dcbff6e253058b4c465dc9bb5fd63 +2026-08-08-unified-github-label-taxonomy.md: 8005629861f306d20293af6b348f19bf79cbe1c3 +2026-08-08-unified-github-label-taxonomy.zh.md: 855a2b98f44d517abe1f7718ae4e81262cb031b6 diff --git a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md index 44508b4f0e..8005629861 100644 --- a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md @@ -27,18 +27,18 @@ The kind set is closed and mutually exclusive: | `kind/cleanup` | Preserves behavior while maintaining or simplifying implementation or repository process. | | `kind/dependency` | Updates dependencies without another dominant intent. | -The kind records the dominant intent. Accompanying tests, documentation, cleanup, or dependency movement do not override a feature or bug fix. A new kind changes this classification contract and requires an explicit taxonomy and policy change. +The kind records the dominant intent. Accompanying tests, documentation, cleanup, or dependency movement do not override a feature or bug fix. A new kind changes these classification rules and requires an explicit taxonomy and policy change. Repository policy rejects unsupported `kind/*` values and reserves every alias removed by the unification: `kind/bug`, `kind/documentation`, `feature`, `bug-fix`, `doc`, `cleanup`, `testing`, `dependencies`, `ci`, `cli`, `llm`, and `web-search`. Reserving the exact migrated set prevents an obsolete synonym from being recreated as an apparently unrelated operational label. ### Areas -Areas name durable semantic domains rather than temporary initiatives, ownership, or every path touched incidentally. A pull request carries multiple areas when it changes distinct contracts, but it does not combine an umbrella and a narrower label for the same contract. GitHub's live `area/*` names and descriptions own the current inventory; this record owns the selection rule and the non-obvious boundaries that cannot fit reliably in short label descriptions. +Areas name durable product or engineering subjects rather than temporary initiatives, ownership, or every path touched incidentally. A pull request carries multiple areas when it changes distinct behavior or APIs, but it does not combine an umbrella and a narrower label for the same change. GitHub's live `area/*` names and descriptions own the current inventory; this record defines selection cases that cannot fit reliably in short label descriptions. - `area/web` covers browser and Electron graphical interfaces, `area/vscode` covers the editor extension, and `area/api` covers cross-interface protocols and language SDKs. - `area/planning` covers goals, plans, todos, and scheduling, while `area/workflow` covers executable workflows and background task runtimes. - `area/artifact` deliberately combines artifacts, attachments, and multimodal delivery. Split labels become justified only when those concerns again need independent review or queries. -- `area/tools` applies to generic registry, schema, and execution contracts. A concrete capability uses its own area unless it also changes that generic contract. +- `area/tools` applies to generic registry, schema, and execution contracts. A concrete capability uses its own area unless it also changes one of those contracts. - `area/hooks` means the Claude Code and Codex bridges, `area/infra` covers build, release, CI, repository gates, generators, dependencies, and developer tooling, and `area/windows` covers native Windows product support rather than CI runner selection. The area set is intentionally extensible. When no existing description honestly covers a durable and reusable domain, an agent may create a concise `area/<lowercase-kebab-case>` label without separate approval. It must not create an area for one pull request, an incidental path, a temporary project, a status, or a person or team, and it reports the new label and rationale to the requester after applying it. Reusing an inaccurate area merely to avoid a justified addition is not acceptable. @@ -61,11 +61,11 @@ Label migrations preserve meaning before removing aliases: add the canonical rep **Separate labels for every delivery shell or media lifecycle.** Browser and Electron delivery share one graphical domain, and artifact, attachment, and multimodal delivery currently share one review/query domain. A split belongs in a later taxonomy change only when it restores useful independent classification. -**Broad implementation labels in place of semantic domains.** A concrete capability is not merely its tool, interface, filesystem, or process implementation. Generic implementation areas apply only when their own contracts change. +**Broad implementation labels in place of product or engineering subjects.** A concrete capability is not merely its tool, interface, filesystem, or process implementation. Generic implementation areas apply only when their own behavior or API changes. **Kinds on Issues.** Native Issue Type already owns that classification; duplicating it as a label creates drift. -**Exactly one area per pull request.** Coherent changes can materially affect several independent contracts, and dropping secondary areas hides affected scope. +**Exactly one area per pull request.** Coherent changes can materially affect several independent APIs or behaviors, and dropping secondary areas hides affected scope. ## Consequences diff --git a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md index b1e3629fd9..855a2b98f4 100644 --- a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md @@ -27,18 +27,18 @@ Issue 已有原生 Issue Type 和独立的来源分类体系。在这两类对 | `kind/cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 | | `kind/dependency` | 在没有其他主导意图时更新依赖。 | -类型记录主导意图。配套测试、文档、清理或依赖调整不会盖过功能变更或缺陷修复这一主导意图。新增类型会改变这项分类约定,因此必须明确修改分类体系和政策。 +类型记录主导意图。配套测试、文档、清理或依赖调整不会盖过功能变更或缺陷修复这一主导意图。新增类型会改变这些分类规则,因此必须明确修改分类体系和政策。 仓库政策会拒绝不支持的 `kind/*` 值,并将统一过程中移除的所有别名列为保留名称:`kind/bug`、`kind/documentation`、`feature`、`bug-fix`、`doc`、`cleanup`、`testing`、`dependencies`、`ci`、`cli`、`llm` 和 `web-search`。精确保留这组已迁移的名称,可以防止过时的同义名称被重新创建成看似无关的管理用途标签。 ### 领域 -领域表示持久的语义领域,而不是临时专项、归属关系或偶然触及的每条路径。一项 PR 修改不同约定时带有多个领域标签,但不会用一个总括标签和一个较窄标签重复描述同一项约定。GitHub 上现行的 `area/*` 名称和说明定义当前清单;本记录定义选择规则,以及简短标签说明无法可靠容纳的非显然边界。 +领域表示持久的产品或工程主题,而不是临时专项、归属关系或偶然触及的每条路径。一项 PR 修改不同的行为或 API 时带有多个领域标签,但不会用一个总括标签和一个较窄标签重复描述同一项变更。GitHub 上现行的 `area/*` 名称和说明定义当前清单;本记录定义简短标签说明无法可靠容纳的选择情形。 - `area/web` 覆盖浏览器与 Electron 图形界面,`area/vscode` 覆盖编辑器扩展,`area/api` 覆盖跨界面协议与各语言 SDK。 - `area/planning` 覆盖目标、计划、待办和调度,`area/workflow` 则覆盖可执行工作流与后台任务运行时。 - `area/artifact` 有意合并产物、附件与多模态交付。只有当这些关注点再次需要独立评审或查询时,才有理由拆分标签。 -- `area/tools` 适用于通用注册表、schema 与执行约定。具体能力使用自身的领域标签,除非它还修改了这项通用约定。 +- `area/tools` 适用于通用注册表、schema 与执行约定。具体能力使用自身的领域标签,除非它还修改了其中一项约定。 - `area/hooks` 表示 Claude Code 与 Codex 桥接,`area/infra` 覆盖构建、发布、CI、仓库门禁、生成器、依赖与开发者工具,`area/windows` 覆盖原生 Windows 产品支持,而不是 CI runner 的选型。 领域集合有意保持可扩展。当现有说明都无法如实涵盖一个持久且可复用的领域时,agent(智能体)无需另行批准,即可创建一个简洁的 `area/<lowercase-kebab-case>` 标签。agent 不得为单个 PR、偶然涉及的路径、临时项目、状态、个人或团队创建领域,并且必须在应用新标签后向请求者报告该标签及理由。仅为避免新增一个确有必要的领域标签而复用不准确的领域,不可接受。 @@ -61,11 +61,11 @@ Issue 使用原生 Issue Type,而不是 `kind/*`;其 `area/*` 标签仍然 **为每种交付载体或媒体生命周期单设标签。** 浏览器与 Electron 交付共用一个图形界面领域,产物、附件与多模态交付目前也共用一个评审/查询领域。只有当拆分能恢复有用的独立分类时,才应在后续分类体系变更中进行。 -**用宽泛的实现标签取代语义领域。** 一项具体能力并不只是其工具、接口、文件系统或进程实现。通用实现领域只在其自身约定变化时适用。 +**用宽泛的实现标签取代产品或工程主题。** 一项具体能力并不只是其工具、接口、文件系统或进程实现。通用实现领域只在其自身行为或 API 变化时适用。 **在 Issue 上使用类型标签。** 原生 Issue Type 已负责这项分类;再用标签复制会造成漂移。 -**每个 PR 恰好一个领域。** 内聚的变更可能对多个独立约定产生实质影响,丢弃次要领域会隐藏受影响范围。 +**每个 PR 恰好一个领域。** 内聚的变更可能对多个独立 API 或行为产生实质影响,丢弃次要领域会隐藏受影响范围。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.i18n.yaml b/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.i18n.yaml index 408f164e06..9997e7252d 100644 --- a/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-09-chinese-contract-terminology.md -2026-08-09-chinese-contract-terminology.md: d2fea45c6864a19b89c78a52f614ccac341a0127 -2026-08-09-chinese-contract-terminology.zh.md: 772b78daba00c5d881fd0091951557dfc0951ee9 +2026-08-09-chinese-contract-terminology.md: fa9e3ab91133a995a63f817816c01b59d28f66e8 +2026-08-09-chinese-contract-terminology.zh.md: 299e9ac9cdb2b8166d2364723b92f878b15cfd66 diff --git a/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.md b/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.md index d2fea45c68..fa9e3ab911 100644 --- a/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.md +++ b/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.md @@ -14,7 +14,7 @@ English `convention` also commonly renders as `约定`. That overlap is intentio The terminology source of truth defines `contract` as `约定` and `adapter contract` as `适配器约定(adapter contract)` on first mention. Every active Chinese documentation pair follows that ruling; archived Agent Notes remain frozen. Unpaired bilingual calibration assets and the translation prompt's explanatory prose follow the same terms so they cannot teach the superseded rendering. -The migration is semantic prose maintenance, not a rename of identifiers. Inline code, file paths, links, API names, English filenames containing `contract`, and machine-readable values remain unchanged. `convention` does not receive a global terminology row or corpus-wide rewrite: translators preserve natural Chinese and explicitly disambiguate only where the source contrasts the two concepts. +The migration is semantic prose maintenance, not a rename of identifiers. Inline code, file paths, links, API names, English filenames containing `contract`, and machine-readable values remain unchanged. `convention` does not receive a global terminology row or corpus-wide rewrite: translators preserve natural Chinese and explicitly disambiguate only where the source contrasts the two concepts. The [concrete prose decision](2026-08-09-concrete-prose-names-actors-and-recorded-facts.md) separately decides when English prose should replace a vague `contract` use with the exact rule, API, or behavior before translation. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.zh.md b/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.zh.md index 772b78daba..299e9ac9cd 100644 --- a/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.zh.md +++ b/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.zh.md @@ -14,7 +14,7 @@ Status: implemented 术语真源规定 `contract` 译为「约定」,`adapter contract` 首次出现时写作「适配器约定(adapter contract)」。所有活跃中文文档配对均遵循该裁决;归档 Agent Note 保持冻结。未参与配对的双语校准资产和翻译提示词说明文字也采用相同术语,避免继续教授已被取代的译法。 -这次迁移只维护语义正文,不重命名标识符。行内代码、文件路径、链接、API 名称、文件名中包含的英文 `contract` 以及机器可读值均保持不变。`convention` 不新增全局术语行,也不做全语料改写:翻译时保留自然中文,只在源文明确对比两个概念时消歧。 +这次迁移只维护语义正文,不重命名标识符。行内代码、文件路径、链接、API 名称、文件名中包含的英文 `contract` 以及机器可读值均保持不变。`convention` 不新增全局术语行,也不做全语料改写:翻译时保留自然中文,只在源文明确对比两个概念时消歧。[具体行文决策](2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)另行规定:如果英文正文中的 `contract` 含糊不清,应在翻译前将其改为确切的规则、API 或行为。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml index 0813498ae6..4c9b955615 100644 --- a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-09-committed-artifact-citations.md -2026-08-09-committed-artifact-citations.md: a578b1e32268af2669ee26c59d19c738bc9f707b -2026-08-09-committed-artifact-citations.zh.md: d525dc0f58884bdf808b597213a7e23c1d6a0301 +2026-08-09-committed-artifact-citations.md: 044f7683d51ebf2038f56d2b5a27755ecc9be6d5 +2026-08-09-committed-artifact-citations.zh.md: 7f194aef45710e2f24e462c99877ec2e112f9981 diff --git a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md index a578b1e322..044f7683d5 100644 --- a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md +++ b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md @@ -18,12 +18,12 @@ Durable prose — comments, JSDoc, docs, notes, test comments and titles — cit - Implemented notes state shipped reality: a "deferred to a later PR" claim whose target shipped names the shipped note instead. - Recorded fixtures, snapshots, and archived notes are exempt: recorded model output and sealed history keep their original voice. Inside a note's change-story sections, a historical stage name ("the first cut shipped X") is current-state-safe; indexical stamps ("this cut") stay banned everywhere. -One repo-wide purge applied these rules across the prose surfaces, including the generator-owned templates (`scripts/gen-doc-graphs.ts`, `scripts/gen-tool-catalog.ts`, the typert generator's page notice) with regeneration, the type-equiv source JSDoc with page re-pastes, and the bilingual counterparts with pair re-records. The [dsh-trim-cot-leakage skill](../../../skills/dsh-trim-cot-leakage/SKILL.md) operationalizes these rules: the audit taxonomy, the committed recall batteries, and few-shot calibration for the keep/delete boundary. +One repo-wide purge applied these rules across the prose surfaces, including the generator-owned templates (`scripts/gen-doc-graphs.ts`, `scripts/gen-tool-catalog.ts`, the typert generator's page notice) with regeneration, the type-equiv source JSDoc with page re-pastes, and the bilingual counterparts with pair re-records. The [dsh-trim-cot-leakage skill](../../../skills/dsh-trim-cot-leakage/SKILL.md) operationalizes these rules: the audit taxonomy, the committed recall batteries, and few-shot examples for deciding what to keep or delete. ## Alternatives considered - **Commit the design ledgers and audit documents so the ordinals resolve.** Rejected: session transcripts are working artifacts, not maintained references; committing them would create a parallel, ungated decision corpus beside Agent Notes, and their internal numbering would still drift. -- **A mechanical gate for the banned vocabulary.** Deferred: the vocabulary is unbounded natural language, and the audit's recall batteries need judgment to separate leakage from legitimate prose ("wait" the noun, contrastive "actually", runtime old/new states). A narrow high-precision gate (for example `\(decision \d`, `\(audit [A-Z]\d`, `\bcut \d`, `this cut`, a bare `\bT\d\b`, `P-I`, `used to `, a bare `\bv1\b`, and `§\d` — the last excluding citations whose section numbering has a committed owner, such as web-styling.md's own §N) is the candidate if the pattern recurs; review of the purge itself caught residuals in exactly these post-battery shapes, so they lead the candidate list. +- **A mechanical gate for the banned vocabulary.** Deferred: the vocabulary is unbounded natural language, and the audit's recall batteries need judgment to separate leakage from legitimate prose ("wait" the noun, contrastive "actually", runtime old/new states). A narrow high-precision gate (for example `\(decision \d`, `\(audit [A-Z]\d`, `\bcut \d`, `this cut`, a bare `\bT\d\b`, `P-I`, `used to `, a bare `\bv1\b`, and `§\d` — the last excluding citations whose section numbering has a committed owner, such as web-styling.md's own §N) is the candidate if the pattern recurs; review of the purge itself caught residuals in exactly the cases those searches missed, so they lead the candidate list. - **Delete the rationale that cited dead artifacts.** Rejected: the factual clauses were preserved or restated; only citations, review choreography, and derivation transcripts were removed, per the prose standard's complete-proposition rule. ## Verification diff --git a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md index d525dc0f58..7f194aef45 100644 --- a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md +++ b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md @@ -18,12 +18,12 @@ Status: implemented - 已实现的 Agent Note 陈述已交付的现实:「推迟到后续 PR」的说法若其目标已经交付,就改为点名那篇已交付的 note。 - 已录制的 fixture(测试前置数据)、快照与已归档的 Agent Note 不受此约束:已录制的模型输出与封存的历史保持原有行文。在 note 的变更故事段落内,历史阶段名称(「首版交付了 X」)属于安全的现状表述;指示性切次戳("this cut")在任何地方都仍被禁止。 -一次全仓库清理把这些规则应用到了各个行文表面,包括生成器持有的模板(`scripts/gen-doc-graphs.ts`、`scripts/gen-tool-catalog.ts`、typert 生成器的页面提示语,改后重新生成)、type-equiv 源码 JSDoc(改后把文档页重新粘贴)以及双语对侧文件(改后重新记录配对)。[dsh-trim-cot-leakage 技能](../../../skills/dsh-trim-cot-leakage/SKILL.md)把这些规则落地为可执行工作流:审计分类法、已提交的成批召回检索,以及校准保留/删除边界的少样本示例。 +一次全仓库清理把这些规则应用到了各个行文表面,包括生成器持有的模板(`scripts/gen-doc-graphs.ts`、`scripts/gen-tool-catalog.ts`、typert 生成器的页面提示语,改后重新生成)、type-equiv 源码 JSDoc(改后把文档页重新粘贴)以及双语对侧文件(改后重新记录配对)。[dsh-trim-cot-leakage 技能](../../../skills/dsh-trim-cot-leakage/SKILL.md)把这些规则落地为可执行工作流:审计分类法、已提交的成批召回检索,以及用于判断保留或删除内容的少样本示例。 ## 曾考虑的替代方案 - **把设计台账与审计文档提交入库,让序号得以解析。**不予采纳:会话 transcript 是工作产物,不是持续维护的参考资料;提交它们会在 Agent Note 之外形成一套平行且不受门禁约束的决策语料,其内部编号也仍会漂移。 -- **为被禁词汇建一道机械门禁。**暂缓:这类词汇是无界的自然语言,审计中以查全为目标的成批检索需要人工判断,才能把泄漏与正当行文区分开(作名词的「wait」、表转折的「actually」、运行时的新旧状态)。若该模式再次出现,候选方案是一道窄而高查准的门禁(例如 `\(decision \d`、`\(audit [A-Z]\d`、`\bcut \d`、`this cut`、裸 `\bT\d\b`、`P-I`、`used to `、裸 `\bv1\b` 与 `§\d`——最后一种需排除章节编号有已提交归属的引用,如 web-styling.md 自身的 §N);对本次清扫自身的评审恰好在这些电池之外的形态中发现残留,因此它们位居候选清单之首。 +- **为被禁词汇建一道机械门禁。**暂缓:这类词汇是无界的自然语言,审计中以查全为目标的成批检索需要人工判断,才能把泄漏与正当行文区分开(作名词的「wait」、表转折的「actually」、运行时的新旧状态)。若该模式再次出现,候选方案是一道窄而高查准的门禁(例如 `\(decision \d`、`\(audit [A-Z]\d`、`\bcut \d`、`this cut`、裸 `\bT\d\b`、`P-I`、`used to `、裸 `\bv1\b` 与 `§\d`——最后一种需排除章节编号有已提交归属的引用,如 web-styling.md 自身的 §N);对本次清扫自身的评审恰好在这些检索未覆盖的案例中发现残留,因此它们位居候选清单之首。 - **删除引用了失效产物的设计理由。**不予采纳:事实性语句都得到保留或改写;依行文标准的完整命题规则,删掉的只有引用、评审编排与推导过程记录。 ## 验证 diff --git a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.i18n.yaml b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.i18n.yaml index b18d088e03..8f9e929996 100644 --- a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-09-concrete-prose-names-actors-and-recorded-facts.md -2026-08-09-concrete-prose-names-actors-and-recorded-facts.md: efb6e9bcc6f1c817bb0be07ccd31bafc4b2ac3fb -2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md: 41b0ce82631536c53ea45d60c1405a8387885d0f +2026-08-09-concrete-prose-names-actors-and-recorded-facts.md: b7df5403ea11ff8ba32be0f9bede5a32d5bcd6ee +2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md: 51a6c42dc75ad04f7d35ef90dd0d54046585dd6d diff --git a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md index efb6e9bcc6..b7df5403ea 100644 --- a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md +++ b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md @@ -18,11 +18,13 @@ The rule applies to Markdown, READMEs, active Agent Notes, JSDoc and comments, p Exact code identifiers, public APIs, durable fields, protocol members, type names, headings with external references, and filenames stay unchanged unless a coordinated contract rename is independently required. Surrounding prose explains their fields or behavior directly. Generated documents and catalogs update from their owning source. +Before using `contract`, `boundary`, or `shape`, writers check whether the sentence means a more specific rule, operation, data structure, field set, validation point, timing point, API, type, or failure condition. `Contract` remains correct for preconditions, postconditions, invariants, compatibility promises, and other obligations that callers, callees, implementers, providers, producers, or consumers rely on. `Boundary` remains correct for a literal security, trust, wire, process, serialization, transaction, or lifecycle division. `Shape` remains correct when the structural form itself is the subject and no narrower term such as fields, schema, type, union variant, file layout, or export form states the fact. Code and API names containing these words remain unchanged unless a separate coordinated rename is required. + This decision complements the [documentation tiers and budgets](2026-07-04-doc-tiers-and-budgets.md) decision, which continues to own placement, document form, and word budgets. ## Alternatives considered -**Ban a fixed list of words.** Rejected because a word may be an exact identifier or the clearest term in another contract. Sentence-level review catches ambiguity without rejecting valid names. +**Ban a fixed list of words.** Rejected because a word may be an exact identifier or the clearest term in another contract. For example, caller/callee invariants are real contracts, and process or wire boundaries identify real divisions. Sentence-level review catches ambiguity without rejecting valid names. **Replace every abstract label with “source,” “origin,” or “metadata.”** Rejected because another broad label still leaves readers to infer whether the sentence means a file, caller, event seq, provider/model pair, commit, or build job. diff --git a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md index 41b0ce8263..51a6c42dc7 100644 --- a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md +++ b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md @@ -18,11 +18,13 @@ Status: implemented 除非另一项独立需求明确要求协调重命名约定,否则确切的代码标识符、公开 API、持久字段、协议成员、类型名、带有外部引用的标题和文件名均保持不变。它们周围的行文直接说明其字段或行为。生成的文档和目录在维护它们的源文件修改后更新。 +使用 `contract`、`boundary` 或 `shape` 之前,写作者要确认句子是否实际指更具体的规则、操作、数据结构、字段集合、校验点、时间点、API、类型或失败条件。调用方、被调用方、实现方、提供方、生产方或消费方依赖的前置条件、后置条件、不变量、兼容性承诺及其他义务仍可准确称为 `contract`。真实的安全、信任、wire、进程、序列化、事务或生命周期分界仍可准确称为 `boundary`。当结构形式本身就是主题,且字段、schema、类型、联合变体、文件布局或导出形式等更窄的词无法说明事实时,仍可使用 `shape`。除非另一项独立需求要求协调重命名,否则包含这些词的代码和 API 名称保持不变。 + 该决策补充了[文档层级与字数预算](2026-07-04-doc-tiers-and-budgets.md)决策;后者继续规定内容位置、文档形式和字数预算。 ## 曾考虑的替代方案 -**禁止一份固定词表中的所有词。** 不予采纳:某个词可能是确切的标识符,也可能是另一项约定中最清楚的用词。逐句审查可以找出歧义,且不会拒绝有效名称。 +**禁止一份固定词表中的所有词。** 不予采纳:某个词可能是确切的标识符,也可能是另一项约定中最清楚的用词。例如,调用方与被调用方依赖的不变量属于真实 contract,进程或 wire boundary 也表示真实分界。逐句审查可以找出歧义,且不会拒绝有效名称。 **将每个抽象名称都替换为“来源”、“起源”或“元数据”。** 不予采纳:另一个宽泛名称仍会让读者自行推测句子指的是文件、调用方、事件 seq、提供方/模型组合、commit 还是构建任务。 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml index 7c4e1fac3d..6ab79c8447 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md -2026-07-29-shared-base-config-overlays.md: b2ca5cc8d3a160dd09550827d5f8148c14048ea4 -2026-07-29-shared-base-config-overlays.zh.md: adec7c793839e84edb14173d71735d264cf2ec28 +2026-07-29-shared-base-config-overlays.md: bbffcda725e3e2c5c0d0ce56a4ee0f1f61559806 +2026-07-29-shared-base-config-overlays.zh.md: e883ee2c5fd5fc887690e85653a9fc07408b6c6f diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md index b2ca5cc8d3..bbffcda725 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md @@ -22,7 +22,7 @@ Precedence is list order, last write winning per row: base, then the surface ove `--config <path>` now applies an overlay **instead of** the personal overlay, so a demo or test tree never inherits the user's provider and model. `--config-replace <path>` boots a file as the entire tree, bypassing base, surface overlay, and personal overlay alike; that is what the old `--config` did, so trees like `examples/web-cordis` moved to the new flag. Both flags survive the `/resume` execve handoff, or resuming would silently change the agent. -A patch replaces its target row's whole `config` rather than merging, which shapes the split: a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity therefore cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as the launcher-owned identity record documented. +A patch replaces its target row's whole `config` rather than merging. Therefore, a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as the launcher-owned identity record documented. `examples/tui-agent`, `examples/cordis-agent`, `examples/code-mode`, and `packages/examples/tui-demo` are deleted. The TUI tests move to `apps/cli/tests/`, the cordis-toolset e2e to `packages/self-modification/tool-cordis/tests/`, and the supported Code Mode demo remains the ACP overlay at `examples/acp-agent/code-mode.cordis.yml`. diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md index adec7c7938..e883ee2c5f 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md @@ -22,7 +22,7 @@ Status: implemented `--config <path>` 现在应用一个 overlay 来**取代**个人 overlay,因此 demo 或测试用的树绝不会继承用户的提供方与 model。`--config-replace <path>` 则把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 `--config` 的行为,所以像 `examples/web-cordis` 这样的树改用了新 flag。两个 flag 都会在 `/resume` 的 execve 交接中保留,否则恢复时会静默更换 agent(智能体)。 -patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆分方式:取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。因此会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,正如启动器持有身份的记录所述。 +patch 会整体替换目标配置项的 `config` 而不合并。因此,取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,正如启动器持有身份的记录所述。 `examples/tui-agent`、`examples/cordis-agent`、`examples/code-mode` 与 `packages/examples/tui-demo` 均被删除。TUI 测试迁往 `apps/cli/tests/`,cordis 工具集的 e2e 迁入 `packages/self-modification/tool-cordis/tests/`,受支持的 Code Mode demo 则保留为 `examples/acp-agent/code-mode.cordis.yml` 中的 ACP(Agent Client Protocol)overlay。 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml index 8fd7d1dd91..b062440270 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md -2026-07-28-storage-root-and-derived-medium-recovery.md: 45e8b3dfcad0590a26581e20c7bc6bb5692e5c36 -2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 330473b2b17669e373a6dc433a74d8bb3ba37628 +2026-07-28-storage-root-and-derived-medium-recovery.md: 9463af00e2a2f5e2d3cee4cc7d386173007abf45 +2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 215e43fac6bb52e1ecaf15d6d9d1ce1b5f913bc3 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md index 45e8b3dfca..9463af00e2 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md @@ -35,7 +35,7 @@ Two independent changes, one per gap. **Launcher patch + a `storageRoot` profile key** — not taken: one `!!js` yml expression reaches the global root with the same layering the session root already has; a launcher patch adds a second rewrite point, and the profile key is an empty seat until a real consumer exists (per-row overrides already have the personal config.yaml patch layer). -**Patch only the projection cache's route to a global root, leave `workspace.json` per-cwd** — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user decision that shaped the cache placed it deliberately beside `workspace.json` — one hub root keeps the media co-located and the mental model single. +**Patch only the projection cache's route to a global root, leave `workspace.json` per-cwd** — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user chose to place the cache beside `workspace.json` — one hub root keeps the media co-located and the mental model single. **Cache-plugin-local recovery (catch damage errors in `SessionProjectionCache[Service.init]`, delete the file, reopen)** — rejected: the plugin cannot name the medium path without reaching around the backend abstraction, and every future derived domain would re-implement the same catch; the facility is the one place that already classifies open failures. diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md index 330473b2b1..215e43fac6 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md @@ -35,7 +35,7 @@ Status: proposed **launcher patch + `storageRoot` profile 键**——未采:一行 yml `!!js` 表达式即达全局根,与会话根的既有分层完全一致;launcher patch 多引入一个改写点,profile 键在有真实消费者前是空席(按行覆盖已有个人 config.yaml patch 层可用)。 -**只把投影缓存的 route 指到全局根,`workspace.json` 留在 per-cwd**——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且塑造缓存的用户决策就是刻意把它放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。 +**只把投影缓存的 route 指到全局根,`workspace.json` 留在 per-cwd**——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且用户选择把缓存放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。 **缓存插件本地恢复(在 `SessionProjectionCache[Service.init]` 捕获损坏错误、删文件、重开)**——拒绝:插件不越过后端抽象就叫不出介质路径,且未来每个派生域都要重抄同一段 catch;facility 是唯一已经在分类 open 失败的地方。 diff --git a/.agents/skills/dsh-archive-agent-notes/SKILL.md b/.agents/skills/dsh-archive-agent-notes/SKILL.md index e4df7e1fe7..ef20de8097 100644 --- a/.agents/skills/dsh-archive-agent-notes/SKILL.md +++ b/.agents/skills/dsh-archive-agent-notes/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-archive-agent-notes -description: Use when adding, auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; checks every new note for superseded active records, classifies implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest contract. +description: Use when adding, auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; checks every new note for superseded active records, classifies implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest rules. --- # Archive DeepSeek Harness Agent Notes @@ -9,18 +9,18 @@ Reduce the active decision corpus without erasing history that can still guide w ## Read the contracts -Read [the Agent Note contract](../../notes/README.md), [the archive instructions](../../notes/archived/AGENTS.md), and the applicable active lifecycle instructions before classifying. Use current code, configuration, package docs, generated catalogs, newer Agent Notes, and inbound links to establish whether a rationale still owns or constrains anything. +Read [the Agent Note rules](../../notes/README.md), [the archive instructions](../../notes/archived/AGENTS.md), and the applicable active lifecycle instructions before classifying. Use current code, configuration, package docs, generated catalogs, newer Agent Notes, and inbound links to establish whether a rationale still owns or constrains anything. ## Check supersession when adding a note -Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note contract's consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit. +Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit. ## Classify by future value Apply these lifecycle-specific outcomes: - **Implemented — keep active:** retain a note when its rationale, alternatives, negative guarantees, durable/wire semantics, ownership boundary, security rule, or reintroduction condition is likely to guide a future change. Length does not matter. -- **Implemented — archive:** archive a note when the shipped decision is complete and its body is unlikely to guide future work, such as one-off UI chrome, a narrow adapter, a minor closed bug, superseded implementation detail, or process history whose current contract is obvious elsewhere. +- **Implemented — archive:** archive a note when the shipped decision is complete and its body is unlikely to guide future work, such as one-off UI chrome, a narrow adapter, a minor closed bug, superseded implementation detail, or process history whose current behavior is obvious elsewhere. - **Proposed — never archive:** keep a live proposal active; if it is no longer worth pursuing, reject it with an honest reason and satisfy the rejected lifecycle format. - **Rejected — keep only as a guardrail:** retain a rejection only when the losing proposal remains a tempting, meaningful mistake and the note explains why it loses. - **Rejected — delete:** delete the whole triplet when the rejected idea is obsolete, superseded, no longer plausible, or unlikely to prevent re-litigation. Repair or delete inbound links. @@ -47,7 +47,7 @@ Keep implemented notes such as: For rejected notes: -- keep folding the compaction package split — 426 words: the package-boundary temptation remains meaningful; +- keep folding the compaction package split — 426 words: the temptation to merge the packages remains meaningful; - delete streaming workflow progress through tool calls — 972 words: its ACP/UI premise is obsolete; - delete dropping ACP terminal metadata — 362 words: the later automation-only ACP decision resolved the question. @@ -65,4 +65,4 @@ After the triplet is sealed, never edit, move, translate, reformat, or delete it Run the archive verifier's focused test, `pnpm run verify-archived-agent-notes`, `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; select any additional evidence through [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md). -Report active implemented notes kept, implemented notes archived, rejected notes kept/deleted, proposed notes rejected if any, and every genuinely borderline case with its word count and chosen outcome. Do not claim archived outbound links are valid: the contract intentionally never checks them. +Report active implemented notes kept, implemented notes archived, rejected notes kept/deleted, proposed notes rejected if any, and every genuinely borderline case with its word count and chosen outcome. Do not claim archived outbound links are valid: the archive verifier intentionally never checks them. diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index c8a52f5d50..572c36a8a1 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -5,11 +5,11 @@ description: Use when reviewing a pull request in the deepseek-harness repo — # Reviewing a DeepSeek-Harness PR -**This skill is guidance, not a complete checklist.** Verify and fetch the PR's live base and exact head, then run `pnpm --silent run change-scope --base <verified-base-ref> --head <verified-head-ref>` before reading the diff and enough surrounding code to understand the design. The report identifies paths and dirty layers but does not replace semantic review. Re-establish the base and rerun it after a retarget or merge. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits. +**This skill is guidance, not a complete checklist.** Verify and fetch the PR's live base and exact head, then run `pnpm --silent run change-scope --base <verified-base-ref> --head <verified-head-ref>` before reading the diff and enough surrounding code to understand the design. The report identifies paths and dirty layers but does not replace semantic review. Re-establish the base and rerun it after a retarget or merge. Prioritize correctness, lifecycle, security, and broken required behavior over style; a short review with one substantiated blocker is better than a list of nits. ## Sources of truth -- [AGENTS.md](../../../AGENTS.md) and [packages/AGENTS.md](../../../packages/AGENTS.md): standing repository and package authoring contracts. +- [AGENTS.md](../../../AGENTS.md) and [packages/AGENTS.md](../../../packages/AGENTS.md): standing repository and package authoring rules. - [docs/defensive-patterns.md](../../../docs/defensive-patterns.md): subprocess, callback, async-state, and disposal bug classes. - [docs/AGENTS.md](../../../docs/AGENTS.md): documentation placement and prose discipline. - [dsh-prose-standard](../dsh-prose-standard/SKILL.md): required coverage and editorial judgment for comments, docs, prompts, and visible strings. @@ -22,22 +22,22 @@ description: Use when reviewing a pull request in the deepseek-harness repo — 1. **New prose receives semantic review.** Use [dsh-prose-standard](../dsh-prose-standard/SKILL.md) to critically review every added or changed Markdown passage, JSDoc, comment, prompt, description, diagnostic, and visible string. Verify required coverage, accuracy, placement, and editorial quality against the owning code or behavior; automated checks do not establish those properties. 2. **Docs match the code.** Config, defaults, errors, wire fields, events, and public behavior update the package README and JSDoc in the same diff. Comments state non-obvious contracts; flag implementation narration, test walkthroughs, review history, and duplicated rationale for deletion or a link to their one home. 3. **Core type docs match.** Changes to spine or seam vocabulary update the appropriate [subsystems](../../../docs/subsystems/README.md) page and any `type-equiv` entry. Internal types need no catalog entry. -4. **Registrations clean up.** Verify each new registry contribution satisfies the disposal-test contract in [packages/AGENTS.md](../../../packages/AGENTS.md). -5. **Invariant companions are semantic.** For every touched `./invariant`, require an owner event-stream or mutable-data relationship at its authoritative boundary; service or method presence, plugin metadata or effects, and fixed pure examples belong in type, load, or unit tests. Accept an empty installer when its package-specific reason establishes that no plausible runtime relationship exists; do not demand an invented check merely to eliminate emptiness ([repository rule](../../../AGENTS.md#conventions); [package contract](../../../packages/AGENTS.md)). +4. **Registrations clean up.** Verify each new registry contribution passes the disposal tests required by [packages/AGENTS.md](../../../packages/AGENTS.md). +5. **Invariant companions are semantic.** For every touched `./invariant`, require an owner event-stream or mutable-data relationship at the point where that package can observe it; service or method presence, plugin metadata or effects, and fixed pure examples belong in type, load, or unit tests. Accept an empty installer when its package-specific reason establishes that no plausible runtime relationship exists; do not demand an invented check merely to eliminate emptiness ([repository rule](../../../AGENTS.md#conventions); [package invariant rules](../../../packages/AGENTS.md)). 6. **Required evidence exists.** Verify the author ran the [relevant local checks](../../../AGENTS.md#run-relevant-checks-locally) for the diff and that CI covers the exhaustive matrix; review the semantic gaps neither can detect. ## Manual checks -- **Intent and seam contracts:** trace both sides of every changed interface. Confirm the implementation matches the PR and any Agent Note, including errors, cancellation, ownership, and disposal. +- **Intent and interface contracts:** trace both sides of every changed interface. Confirm the implementation matches the PR and any Agent Note, including errors, cancellation, ownership, and disposal. - **Lifecycle and concurrency:** for async setup, callbacks, processes, or teardown, apply [defensive-patterns.md](../../../docs/defensive-patterns.md). Check races before publication, cancellation during awaits, independent error reporting, callback containment, ownership before reentry, complete detach cleanup, and quiescent disposal. -- **Capability and consumer fit:** trace every current consumer, then flag consumer-specific behavior leaking into the interface under [the package contract](../../../packages/AGENTS.md). Flag the inverse too: a new public method on a generic service (registry, session, agent) whose only caller is one internal consumer is an ad-hoc surface widening — require a private capability closure handed to that consumer at construction instead. -- **Scope, ownership, and necessity:** map each abstraction, state machine, option, defensive copy, and compatibility path to its current contract, production consumer, and owning plugin or service. Challenge unrelated features and speculative generality, then test the PR's coherence against [the root contract](../../../AGENTS.md#conventions). +- **Capability and consumer fit:** trace every current consumer, then flag consumer-specific behavior leaking into the interface under [the package rules](../../../packages/AGENTS.md). Flag the inverse too: a new public method on a generic service (registry, session, agent) whose only caller is one internal consumer is an unnecessary API expansion — require a private capability closure handed to that consumer at construction instead. +- **Scope, ownership, and necessity:** map each abstraction, state machine, option, defensive copy, and compatibility path to its current contract, production consumer, and owning plugin or service. Challenge unrelated features and speculative generality, then test the PR against [the root rules](../../../AGENTS.md#conventions). - **Configuration and public choices:** ask what current-consumer evidence or prior art supports each default, public operation set, format, or imported external concept. Require an explicit choice or deferral when that evidence is absent. - **Model perspective:** inspect the exact prompts, tool schemas, results, and diagnostics the model receives across affected modes. Flag concepts outside the model's task, then verify stable text verbatim and dynamic behavior through snapshots or end-to-end coverage. -- **Enforcement boundaries:** follow every denial path to the operation that executes it; exercise direct and alternate callers that can bypass schemas, prompts, facades, wrappers, or listener ordering. -- **Borrowed and derived state:** classify each retained value under the package boundary contract, then trace notifications and every cache, prompt, UI echo, replay, and query view to the documented success point and authoritative source. +- **Enforcement:** follow every denial path to the operation that executes it; exercise direct and alternate callers that can bypass schemas, prompts, facades, wrappers, or listener ordering. +- **Borrowed and derived state:** determine whether each retained value is borrowed or owned under the package contract, then trace notifications and every cache, prompt, UI echo, replay, and query view to the documented success point and authoritative source. - **Bounds cover the final operation:** locate the owner of the complete emitted or retained result, including wrappers and metadata. Probe tiny and exact limits, oversized single chunks, and multibyte text for byte limits. -- **Real entry path:** tests exercise the shipped Loader, bin, worker, ACP bridge, or subprocess where relevant. A hand-mounted plugin does not catch Loader export-shape failures; a function plugin must named-export its namespace and have no default export. +- **Real entry path:** tests exercise the shipped Loader, bin, worker, ACP bridge, or subprocess where relevant. A hand-mounted plugin does not catch invalid Loader exports; a function plugin must named-export its namespace and have no default export. - **Test strength:** assertions fail on the intended regression and verify external state, logs, events, or disposal rather than restating the implementation or trusting an agent's report. Coverage is necessary but not evidence that the scenario is correct. - **Invariant lifecycle and negative controls:** verify candidate observations are rejected before publication where possible, session-backed checks reconstruct durable history after late loading or HMR, and a deliberately invalid case fails through the real runner for the intended rule. - **Implemented Agent Notes match shipped reality:** when a PR implements a proposed Agent Note, move and rewrite it as present-tense shipped state in the same diff, then verify paths, names, and mechanisms against the implementation. diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md index 3f93a6560a..5d118f2257 100644 --- a/.agents/skills/dsh-doc-site-sync/SKILL.md +++ b/.agents/skills/dsh-doc-site-sync/SKILL.md @@ -38,7 +38,7 @@ Set every `DocsPage` field deliberately: - `order`: stable order within the section. - `sourceAliases`: optional additional repository paths that should resolve to this page when links are projected. It does not create another public route. -Use `mirroredPages()` only for a source that intentionally falls back to the same available language in both route trees. Convert that entry to `pairedPages()` when its counterpart is added. Keep the manifest an explicit public allowlist. Do not publish RFCs, postmortems, testing guides, `AGENTS.md`, or maintainer workflows merely because they exist under `docs/`; add internal material only when the user explicitly changes the publication boundary. +Use `mirroredPages()` only for a source that intentionally falls back to the same available language in both route trees. Convert that entry to `pairedPages()` when its counterpart is added. Keep the manifest an explicit public allowlist. Do not publish RFCs, postmortems, testing guides, `AGENTS.md`, or maintainer workflows merely because they exist under `docs/`; add internal material only when the user explicitly expands what the site publishes. ## Preserve link behavior diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index dcbf2f2b21..a42c27cfda 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -5,25 +5,25 @@ description: 'Use when writing, moving, reviewing, or auditing documentation in # Applying the DeepSeek Harness Documentation Standard -The contract lives in [docs/AGENTS.md](../../../docs/AGENTS.md). This workflow covers placement, corpus audits, budgets, and validation across Markdown, JSDoc, and code comments. It is guidance, not a script; use [dsh-prose-standard](../dsh-prose-standard/SKILL.md) for required coverage and editorial judgment, and never treat length alone as a defect. +The documentation rules live in [docs/AGENTS.md](../../../docs/AGENTS.md). This workflow covers placement, corpus audits, budgets, and validation across Markdown, JSDoc, and code comments. It is guidance, not a script; use [dsh-prose-standard](../dsh-prose-standard/SKILL.md) for required coverage and editorial judgment, and never treat length alone as a defect. ## Sources of truth (read, don't re-summarize) - [docs/AGENTS.md](../../../docs/AGENTS.md) — hierarchy, tutorial/reference forms, taxonomy, budgets, and slop checklist. - [.agents/notes/README.md](../../notes/README.md) — when a decision earns an Agent Note, how to file it, and what goes inside one (the header block, per-lifecycle skeleton, and Alternatives-considered mandate, gated by `verify-agent-note-format`); [docs/postmortem/README.md](../../../docs/postmortem/README.md) — when an incident earns a postmortem. -- [docs/i18n/README.md](../../../docs/i18n/README.md) — the bilingual pairing contract; editing either side of a pair obligates the counterpart in the same change. +- [docs/i18n/README.md](../../../docs/i18n/README.md) — the bilingual pairing rules; editing either side of a pair obligates the counterpart in the same change. - Root [AGENTS.md](../../../AGENTS.md) — the standing orders whose budget discipline this skill protects. - [Archived Agent Notes](../../notes/archived/AGENTS.md) — frozen historical snapshots excluded from editorial maintenance and evolving documentation gates. ## Review structure before prose -Apply the standard's authoring order to every human-facing document in scope. Do not apply this structural pass to Agent Notes. Classify a postmortem as a reference scoped to one incident; preserve the chronological evidence required by its contract without treating chronology as a teaching sequence. +Apply the standard's authoring order to every human-facing document in scope. Do not apply this structural pass to Agent Notes. Classify a postmortem as a reference scoped to one incident; preserve its required chronological evidence without treating chronology as a teaching sequence. 1. Locate the document in the repository and navigation trees. State its own subject and identify its direct children. -2. Set the detail boundary. Keep full detail about the document's subject, summarize direct children by purpose, responsibility, and high-level behavior, and move deeper explanations to their owning descendants with links. Treat test infrastructure as descendant-owned unless it is the document's subject. +2. Set the permitted level of detail. Keep full detail about the document's subject, summarize direct children by purpose, responsibility, and high-level behavior, and move deeper explanations to their owning descendants with links. Treat test infrastructure as descendant-owned unless it is the document's subject. 3. Classify the document from its intended use, not its path or title. A tutorial must lead through ordered work to an observable outcome; a reference must support lookup within an explicit scope without requiring sequential reading. 4. For a tutorial, privately classify the starting reader and concepts as beginner, intermediate, or advanced. Trace each concept to its prerequisites, reorder premature material, and move optional advanced detail to a later tutorial or reference. -5. Split substantial mixed forms. Keep a small secondary form only behind a clear structural boundary. +5. Split substantial mixed forms. Put a small secondary form in a clearly labeled section. Then check constraints that make placement expensive or wrong: @@ -37,7 +37,7 @@ Then check constraints that make placement expensive or wrong: After the structural pass, hunt the standard's slop checklist with the cheapest probes first. Verify and fetch the PR's live base, then run `pnpm --silent run change-scope --base <verified-base-ref>` to identify committed and dirty paths before applying semantic judgment. After a retarget or base merge, rerun the report and audit prose introduced by the new base. 1. Measure: `pnpm run verify-doc-budgets --list`, then `git ls-files '*.md' ':(exclude)vendor/**' | xargs wc -w | sort -rn | head -30` to spot unbudgeted outliers. -2. Hunt reasoning-transcript leakage — narrated history, dead design-session citations, review choreography, control-flow narration, test walkthroughs — with [dsh-trim-cot-leakage](../dsh-trim-cot-leakage/SKILL.md), which owns the taxonomy, recall batteries, and the keep/delete boundary. Preserve only a non-obvious contract or durable rationale; the same rationale repeated beside sibling methods keeps one home. +2. Hunt reasoning-transcript leakage — narrated history, dead design-session citations, review choreography, control-flow narration, test walkthroughs — with [dsh-trim-cot-leakage](../dsh-trim-cot-leakage/SKILL.md), which defines the taxonomy, recall batteries, and rules for what to keep or delete. Preserve only a non-obvious contract or durable rationale; the same rationale repeated beside sibling methods keeps one home. 3. Hunt duplication by grepping distinctive phrases. Keep one home and replace other copies with links. 4. Replace hand-written catalogs, test/status inventories, and JSDoc restatements with the authoritative tree, script, or generated reference. 5. In `implemented/` Agent Notes, remove migration plans, acceptance-task checklists, and future-tense spec language. Keep concise verification contracts that identify the behaviors and tiers pinning the shipped decision, plus named coverage gaps. diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 33e5b4fd88..88b025e298 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -11,19 +11,19 @@ This skill helps turn a broad "find things to simplify" request into evidence-ba - Read `AGENTS.md`, especially the pre-release stance and the conventions (including the tests-are-not-golden-truth and Agent Notes-are-not-golden-truth doctrines), plus [docs/defensive-patterns.md](../../../docs/defensive-patterns.md) and [docs/testing.md](../../../docs/testing.md). - Skim [docs/architecture.md](../../../docs/architecture.md) before judging anything under `packages/`; simplifications that fight the service map or event taxonomy need extra evidence. -- Use the Agent Note tree and its [contract](../../notes/README.md) to understand intentional architecture. The most relevant implemented examples are [drop mutable session summary](../../notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md), [shared persistence write coordinator](../../notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), [capability seams](../../notes/implemented/architecture/2026-06-13-capability-seams.md), and the twin adapter / dual persistence backend Agent Notes. +- Use the Agent Note tree and its [rules](../../notes/README.md) to understand intentional architecture. The most relevant implemented examples are [drop mutable session summary](../../notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md), [shared persistence write coordinator](../../notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), [capability seams](../../notes/implemented/architecture/2026-06-13-capability-seams.md), and the twin adapter / dual persistence backend Agent Notes. - Treat dual LLM adapters and dual persistence backends as intentional by default. Do not propose deleting either twin/backend as "low effort" unless the user explicitly overrides that constraint. Removing an unused method or hook inside a protected seam can still be valid if it does not collapse the protected design. ## What Counts As A Strong Candidate -A strong simplification removes, folds, or demotes something real and has clear evidence that the current shape costs more than it buys: +A strong simplification removes, folds, or demotes something real and has clear evidence that the current design costs more than it buys: - A public method, event, config knob, registry notification, helper, package, durable event, or test artifact has no production consumer. - Tests or docs are the only consumers, and the behavior they pin is not load-bearing. - Two representations mirror the same fact, especially across durable session events and transient `agent/*` events. - A seam has methods every implementation must support but no consumer uses. -- A package boundary exists only for test/demo/support code and adds publish or dependency overhead. -- A feature implements speculative product generality: multi-session/session-load, background task rosters, live registry invalidation, mid-turn steering, tool-owned UI rendering, and similar shapes with no product owner. +- A separate package exists only for test/demo/support code and adds publish or dependency overhead. +- A feature implements speculative product generality: multi-session/session-load, background task rosters, live registry invalidation, mid-turn steering, tool-owned UI rendering, and similar designs with no product owner. - An invariant, rollback path, set of expected outputs, or special-case test exists only to protect an unused surface. - Hand-rolled code reimplements what a well-maintained external package or a Node builtin at the engine floor already provides, and the swap would delete the implementation plus its dedicated tests ([dependency policy](../../notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.md)). - The simplified behavior may differ slightly, but the new behavior is still reasonable and easier to explain. @@ -38,7 +38,7 @@ Use parallel subagents when the user asks for breadth or many candidates. Give e - ACP automation and human UI surfaces: prompt settlement and teardown on the protocol side; transcript rendering and interaction state on the UI side. - LLM/tools/system prompt: stream/generate surfaces, assemblers, registries, tool schema defaults, presentation hooks. - Bash and tool execution: foreground/background split, task ownership, output spill files, executor methods. -- Packages/examples/scripts/tests: package boundaries, static inventories, redundant snapshot expected outputs, support packages. +- Packages/examples/scripts/tests: package splits, static inventories, redundant snapshot expected outputs, support packages. If subagents are unavailable, simulate the same breadth yourself. Do not let the first good candidate stop the survey. @@ -46,9 +46,9 @@ Start with the largest production-code deltas. A broad simplification audit that ## Audit Trust And Lifecycle Boundaries -Classify every defensive copy, freeze, validator, and callback capture by the boundary it crosses. Same-process typed service/plugin calls ordinarily borrow readonly values; parser/config, queue, model/tool JSON, durable/file, worker, process, and wire boundaries own or validate data. Tests built around hostile getters, fake typed objects, callback replacement, or mutation after a same-process handoff are evidence of a potentially speculative contract, not automatic justification for keeping it. +For every defensive copy, freeze, validator, and callback capture, name where the value came from and who owns it next. Same-process typed service/plugin calls ordinarily borrow readonly values; parsers, config loaders, queues, model/tool JSON, durable files, workers, processes, and wire decoders own or validate their data. Tests built around hostile getters, fake typed objects, callback replacement, or mutation after a same-process handoff are evidence of a potentially speculative contract, not automatic justification for keeping it. -For complex asynchronous code, draw the ownership graph and map each sentinel, readiness promise, cancellation path, disposer, and state flag to a distinct owner or transition. When several mechanisms mirror the same liveness or settlement fact, propose one transaction or lifecycle controller instead. Preserve separate machinery where it protects a real boundary: synchronous publication and rollback, callback containment, first-terminal-outcome arbitration, worker/process ownership, or dispose-to-quiescence. +For complex asynchronous code, draw the ownership graph and map each sentinel, readiness promise, cancellation path, disposer, and state flag to a distinct owner or transition. When several mechanisms mirror the same liveness or settlement fact, propose one transaction or lifecycle controller instead. Preserve separate machinery where it protects synchronous publication and rollback, callback containment, first-terminal-outcome arbitration, worker/process ownership, or dispose-to-quiescence. ## Hand-Rolled Code Versus A Dependency @@ -75,7 +75,7 @@ Reject or downgrade a candidate when: - A production caller exists and the simplification would be a feature decision rather than a cleanup. - The surface is explicitly justified by an implemented Agent Note or a hard-won defensive pattern, and the new evidence does not beat that reason. -- The removal would force unrelated churn without actually making the contract smaller. +- The removal would force unrelated churn without actually reducing the public API or required behavior. - The idea is correct but tiny. Add a targeted TODO/FIXME/XXX instead, using the urgency semantics in [docs/development.md](../../../docs/development.md). ## Coalesce Superseded Agent Notes @@ -84,23 +84,23 @@ Audit the Agent Note tree when the user asks to reduce or coalesce it, or when t Use [`dsh-archive-agent-notes`](../dsh-archive-agent-notes/SKILL.md) for retention judgment and archive mechanics. Low-future-value implemented notes move as frozen triplets to `archived/{kind}`; proposed notes are never archived; rejected notes that no longer prevent a tempting mistake are deleted. Do not edit an archived note while simplifying current prose or code. -Follow the deletion rule in the [Agent Note contract](../../notes/README.md#when-to-write-one); do not duplicate or weaken it here. For each candidate chain: +Follow the deletion rule in the [Agent Note rules](../../notes/README.md#when-to-write-one); do not duplicate or weaken it here. For each candidate chain: 1. Identify the current owner from shipped code, configuration, generated catalogs, package docs, newer Agent Notes, and inbound links; dates and titles are discovery hints, not proof. 2. Classify the old note as fully or partially superseded. Any surviving behavior, current contract, durable format, compatibility obligation, or independently current rejected alternative makes it partial. Rationale that can be transferred to the current owner does not by itself make supersession partial. -3. For full supersession, move every unique rationale, alternative, consequence, shipped verification contract, and named coverage gap into the current owner. An inventory that only describes deleted implementation mechanics is not one of those decision facts. +3. For full supersession, move every unique rationale, alternative, consequence, shipped verification evidence, and named coverage gap into the current owner. An inventory that only describes deleted implementation mechanics is not one of those decision facts. 4. Repair every inbound link, then delete the English note, Chinese counterpart, and consistency record together. 5. Search exact filenames, symbols, config keys, event names, and wire strings after the edit. Keep partial supersessions cross-linked and current. -An added-then-removed feature is a common full-supersession case. Let the removal note own the history only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that enforce absence may remain. Preserve why the feature originally existed, why that motivation no longer justified it, alternatives to full removal, the capability given up, conditions for reintroduction, and evidence that removal is complete. Old tests and implementation mechanics that verified only the deleted behavior are not current verification contracts. +An added-then-removed feature is a common full-supersession case. Let the removal note own the history only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that enforce absence may remain. Preserve why the feature originally existed, why that motivation no longer justified it, alternatives to full removal, the capability given up, conditions for reintroduction, and evidence that removal is complete. Old tests and implementation mechanics that verified only the deleted behavior are not current verification evidence. Reject consolidation when the removal is only one transport, default, implementation, or presentation of a feature; when persisted data or compatibility handling survives; or when the removal note does not yet carry enough rationale to prevent accidental reintroduction. A current negative design decision may legitimately need its own note even though the removed implementation is gone. ## Write The Agent Note -Create one file per durable proposal under `.agents/notes/<lifecycle>/<class>/yyyy-mm-dd-topic.md`, following the lifecycle/classification contract in `.agents/notes/README.md`. Keep prose paragraphs on one physical line and use relative Markdown links. +Create one file per durable proposal under `.agents/notes/<lifecycle>/<class>/yyyy-mm-dd-topic.md`, following the lifecycle and classification rules in `.agents/notes/README.md`. Keep prose paragraphs on one physical line and use relative Markdown links. -Prefer this shape, adjusting when the idea needs it: +Prefer this structure, adjusting when the idea needs it: - `# Agent Note: <action-oriented title>` - `Status: proposed` diff --git a/.agents/skills/dsh-prose-standard/SKILL.md b/.agents/skills/dsh-prose-standard/SKILL.md index 2381d459c2..6ad226baf4 100644 --- a/.agents/skills/dsh-prose-standard/SKILL.md +++ b/.agents/skills/dsh-prose-standard/SKILL.md @@ -5,7 +5,9 @@ description: Use when writing, reviewing, restoring, trimming, or auditing prose # DeepSeek Harness Prose Standard -Write enough to preserve the contract, then remove reasoning transcripts, repetition, and decoration. This skill owns editorial judgment and required prose coverage; use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) for placement, budgets, bilingual pairs, and documentation gates, and [dsh-trim-cot-leakage](../dsh-trim-cot-leakage/SKILL.md) for hunting and fixing reasoning-transcript leakage. It is guidance, not a script. +Write enough to preserve the contract, then remove reasoning transcripts, repetition, and decoration. A contract is an obligation, invariant, precondition, postcondition, or compatibility promise that a caller, callee, implementer, producer, or consumer relies on. This skill owns editorial judgment and required prose coverage; use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) for placement, budgets, bilingual pairs, and documentation gates, and [dsh-trim-cot-leakage](../dsh-trim-cot-leakage/SKILL.md) for hunting and fixing reasoning-transcript leakage. It is guidance, not a script. + +Treat `contract`, `boundary`, `shape`, `surface`, `seam`, `gate`, and `vocabulary` as terms to check before use, not banned words. First ask whether the exact rule, API, field set, type, validation, timing point, component split, or failure states the fact better. Keep a term when it names the exact technical subject, including caller/callee contracts and security/process boundaries. Comments describe non-obvious contracts or rationale that code cannot express; they do not restate what code already implies. @@ -45,14 +47,14 @@ This is not a one-way shortening pass. Add or restore prose when code, types, an - **Public JSDoc:** document caller-visible return distinctions, throws or rejections, side effects, ownership, timing, cancellation, and durability. - **Internal comments:** orient non-local structure and obviously complicated local structure, including invariants, race ordering, ownership, security boundaries, and surprising failure behavior. Delete control-flow narration and code restatement. -- **Module comments:** state the module's role, boundaries, and non-obvious architecture choices; link architecture choices to their owning explanation. +- **Module comments:** state the module's role, dependencies, responsibilities, and non-obvious architecture choices; link architecture choices to their owning explanation. - **Tests:** explain only non-obvious test design—why a fixture, assertion, platform accommodation, real entry path, or indirect observation is necessary. Delete walkthroughs and inventories. - **Cookbooks:** include prerequisites, required actions, the real entry path, observable verification, and concise warnings. -- **READMEs:** include the consumer contract: configuration, semantics, failures, limitations, extension points, and model-visible effects. Quote stable model-visible text owned by the package; link generated catalogs and cross-package owners. Keep durable gaps and maintainer traps, not ordinary cleanup inventories. Follow the [package README contract](../../../docs/cookbook/adding-a-package.md#4-write-the-package-readme). -- **Agent Notes:** retain unique rationale, mechanisms, alternatives, consequences, shipped verification contracts, and named coverage gaps. Implemented Agent Notes state shipped reality in the present tense; remove planning checklists, not evidence of what pins the decision. +- **READMEs:** include the consumer contract: configuration, semantics, failures, limitations, extension points, and model-visible effects. Quote stable model-visible text owned by the package; link generated catalogs and cross-package owners. Keep durable gaps and maintainer traps, not ordinary cleanup inventories. Follow the [package README requirements](../../../docs/cookbook/adding-a-package.md#4-write-the-package-readme). +- **Agent Notes:** retain unique rationale, mechanisms, alternatives, consequences, shipped verification evidence, and named coverage gaps. Implemented Agent Notes state shipped reality in the present tense; remove planning checklists, not evidence of what pins the decision. - **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality. - **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth. -- **Examples and configuration comments:** explain boundaries, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows. +- **Examples and configuration comments:** explain access limits, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows. - **Prompts and visible strings:** treat wording as behavior. Inspect generated output and run behavior validation or state why no snapshot applies. - **Diagnostics:** name the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration. @@ -70,7 +72,7 @@ Preserve searchable mechanism names and meaningful modal, temporal, or negative ## Borderline decisions -A case is borderline only when at least two versions satisfy the complete-proposition rule but trade accepted principles, and this skill does not already resolve the tradeoff. A new prose shape with one contract-preserving answer is not borderline. +A case is borderline only when at least two versions satisfy the complete-proposition rule but trade accepted principles, and this skill does not already resolve the tradeoff. A rewrite with one proposition-preserving answer is not borderline. In automatic mode, apply clear edits when authorized and report genuine borderline cases without asking questions. Do not weaken a proposition to make progress. diff --git a/.agents/skills/dsh-prose-standard/references/examples.md b/.agents/skills/dsh-prose-standard/references/examples.md index c4270ecc98..cb3f616b71 100644 --- a/.agents/skills/dsh-prose-standard/references/examples.md +++ b/.agents/skills/dsh-prose-standard/references/examples.md @@ -40,13 +40,13 @@ Keep the test tiers, required action, real entry path, and observable verificati **Over-detailed:** A chronological account of every promise and callback used to implement teardown. -The actor, ordering, ownership boundary, and completion guarantee are separate factual clauses. +The actor, ordering, point where ownership changes, and completion guarantee are separate factual clauses. ## Event JSDoc preserves boundary timing **Over-trimmed:** “Composes and caches the session prefix.” -**Balanced:** “Composes the session prefix once before the first pre-step and request boundary. Listener appends join the current request, and pre-step pressure accounting receives the composed prefix.” +**Balanced:** “Composes the session prefix once before the first pre-step and model request. Listener appends join the current request, and pre-step pressure accounting receives the composed prefix.” **Over-detailed:** A walkthrough of the loop helpers, cache fields, and promise callbacks that implement the ordering. @@ -60,7 +60,7 @@ Event order and its current-request consequence are caller-visible behavior, not **Over-detailed:** A paragraph-by-paragraph preview of the classes and helper functions below. -Keep role, boundaries, and non-obvious lifecycle behavior. Link architecture rationale and let the code show local control flow. +Keep the module's role, dependencies, responsibilities, and non-obvious lifecycle behavior. Link architecture rationale and let the code show local control flow. ## Public JSDoc includes failures @@ -76,11 +76,11 @@ Throws and state preconditions are caller-visible contract facts. **Over-trimmed:** “Search provider backed by an external API.” -**Balanced:** “Maps each provider result to the shared search-result shape, preserving the title, URL, and text while omitting provider-only ranking metadata.” +**Balanced:** “Maps each provider result to the shared search-result fields, preserving the title, URL, and text while omitting provider-only ranking metadata.” **Over-detailed:** A field-by-field restatement of the mapping code, including fields with identical names and obvious assignments. -Keep mapping details that explain an abstraction boundary or intentional information loss. +Keep mapping details that explain where an adapter drops or changes information. ## Link rationale while keeping the local contract @@ -110,7 +110,7 @@ Remove migration tasks and test narration. Keep the tiers, behaviors they pin, r **Over-detailed:** A list of every service a plugin could misuse and every hypothetical exploit. -Keep one example when it makes an otherwise abstract boundary operationally clear. +Keep one example when it makes an otherwise abstract security limit operationally clear. ## Delete reasoning transcripts entirely @@ -134,7 +134,7 @@ Keep the consequence of order, a surprising scope rule, or a security boundary. **Shorter but worse:** “The adapter normalizes provider errors.” -**Balanced decision:** Keep the current sentence unless a link or surrounding contract already carries the failure categories. The shorter version loses the consequence and distinctions without improving structure. +**Balanced decision:** Keep the current sentence unless a link or surrounding contract already lists the failure categories. The shorter version loses the consequence and distinctions without improving structure. ## Model-visible text follows ownership @@ -154,7 +154,7 @@ Wording that reaches a model is behavior, but duplication still drifts. Exactnes **Balanced:** “Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session.” Keep non-catalog detail in later sentences. -Know what the generator extracts. That fragment must preserve the contract needed on its generated surface. +Know what the generator extracts. That fragment must preserve the contract needed on its generated output. ## Limitations are contracts, not debt inventories diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index e35096a5a2..586eb554c8 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -64,4 +64,4 @@ When translations need to be written from scratch, the orchestrating agent does ## How to respond to translation review -Follow the [code-review reporting guidance](../dsh-code-review/SKILL.md#reporting-findings): evaluate each comment on its merits, and for terminology comments, remember the table is the contract — a reviewer's rendering decision gets applied to [terminology.md](../../../docs/i18n/terminology.md) so it binds every future translation, not just patched into one file. +Follow the [code-review reporting guidance](../dsh-code-review/SKILL.md#reporting-findings): evaluate each comment on its merits, and for terminology comments, remember the terminology table is the contract — apply a reviewer's rendering decision to [terminology.md](../../../docs/i18n/terminology.md), not only to one file. diff --git a/.agents/skills/dsh-trim-cot-leakage/SKILL.md b/.agents/skills/dsh-trim-cot-leakage/SKILL.md index c80dfbd465..7fad4d50c2 100644 --- a/.agents/skills/dsh-trim-cot-leakage/SKILL.md +++ b/.agents/skills/dsh-trim-cot-leakage/SKILL.md @@ -24,7 +24,7 @@ For every suspect passage ask: **could a reader at HEAD, with no access to any s ## What is not leakage -The citation boundary is where unaided passes fail in both directions — deleting durable references and keeping dead ones. Apply these keeps as written; [examples](references/examples.md) calibrates each: +Unaided citation passes fail in both directions by deleting durable references and keeping dead ones. Apply these keep rules as written; [examples](references/examples.md) calibrates each: - **Issue references** — `#1470`, `TODO(name):`, "issue #N owns the follow-up" resolve at HEAD; keep them on any surface, including READMEs. Do not relocate them to Agent Notes. - **Merged-PR and issue citations inside Agent Notes and postmortems** — sanctioned evidence per the [documentation standard](../../../docs/AGENTS.md)'s change-story routing. @@ -39,7 +39,7 @@ The citation boundary is where unaided passes fail in both directions — deleti ## Workflow 1. Scope and exclusions per [dsh-prose-standard](../dsh-prose-standard/SKILL.md): require an explicit scope; never touch `vendor/`, `.agents/notes/archived/`, or recorded fixtures and snapshots — recorded model output and sealed history keep their original voice. -2. Audit read-only first: run the [recall batteries](references/recall-batteries.md) (with `--hidden` so `.agents/` is searched), then judge every hit semantically. The batteries are probes, not the definition — each review round of the original purge surfaced shapes the batteries missed, so also read the densest prose in scope (module JSDoc, READMEs, Agent Notes) without a pattern in hand. +2. Audit read-only first: run the [recall batteries](references/recall-batteries.md) (with `--hidden` so `.agents/` is searched), then judge every hit semantically. The batteries are probes, not the definition — each review round of the original purge found cases the batteries missed, so also read the densest prose in scope (module JSDoc, READMEs, Agent Notes) without a pattern in hand. 3. Fix owner-first per surface: generated catalogs → fix the source JSDoc or generator template, then regenerate; type-equivalence fences → fix the source JSDoc, then re-paste both bilingual pages (`verify-type-equiv` pins them); bilingual pairs → update the counterpart and re-record per [dsh-translate-docs](../dsh-translate-docs/SKILL.md); model-visible strings → wording is behavior, so flag for a snapshot-backed change instead of silently rewording. 4. Before deleting anything, enumerate the passage's propositions (prose-standard) and check the [overcorrection traps](references/examples.md#overcorrection-traps): trims that flip an obligation into an endorsement, promote a hypothetical to a shipped feature, delete a true fact, or drop provenance. 5. Verify: re-run the batteries expecting only sanctioned keeps, this skill's own directory, and the owning note's quoted evidence; confirm every remaining citation resolves at HEAD; run the gates for touched surfaces (`doc-sync` for docs, `verify-type-equiv`, `verify-translation-pairing`). diff --git a/.agents/skills/dsh-trim-cot-leakage/references/examples.md b/.agents/skills/dsh-trim-cot-leakage/references/examples.md index 22e73e10f4..9afc22c209 100644 --- a/.agents/skills/dsh-trim-cot-leakage/references/examples.md +++ b/.agents/skills/dsh-trim-cot-leakage/references/examples.md @@ -1,6 +1,6 @@ # Few-shot leakage examples -Distilled from the 2026-08 repo-wide purge and its review rounds. Use them to identify the governing principle, not as text templates. This file deliberately quotes leaked shapes as calibration material — the [recall batteries](recall-batteries.md) exclude the skill's directory, and its wording is not a license elsewhere. +Distilled from the 2026-08 repo-wide purge and its review rounds. Use them to identify the governing principle, not as text templates. This file deliberately quotes leaked wording as calibration material — the [recall batteries](recall-batteries.md) exclude the skill's directory, and its wording is not a license elsewhere. ## Dead citations diff --git a/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md b/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md index 8f9cce4aad..9b1c79054c 100644 --- a/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md +++ b/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md @@ -1,12 +1,12 @@ # Recall batteries -Probes for [the taxonomy](../SKILL.md#taxonomy), tuned during the 2026-08 purge. Every hit needs semantic judgment — the batteries over-match by design, and they under-match by nature: each review round of the purge found shapes no battery caught, so pair them with an unpatterned read of the densest prose in scope. +Probes for [the taxonomy](../SKILL.md#taxonomy), tuned during the 2026-08 purge. Every hit needs semantic judgment — the batteries over-match by design, and they under-match by nature: each review round of the purge found cases no battery caught, so pair them with an unpatterned read of the densest prose in scope. ## Invocation rules - Add `--hidden --glob '!.git/**'` so `.agents/` is searched; ripgrep skips dot-directories by default and the purge's biggest miss risk was Agent Notes. -- Exclusions go last so a later include cannot re-admit them: `--glob '!vendor/**' --glob '!node_modules/**' --glob '!.agents/notes/archived/**' --glob '!.agents/skills/dsh-trim-cot-leakage/**'` (the skill's own files quote leaked shapes as calibration), plus recorded fixture and snapshot directories in scope. The [owning note](../../../notes/implemented/process/2026-08-09-committed-artifact-citations.md) also self-hits through its quoted evidence; judge it as evidence, not usage. -- Natural-language lines carry `-i` so sentence-initial capitals hit ("This PR adds…", "Probably fine…"); the code-shaped first line stays case-sensitive — `-i` would turn `\bT\d\b` and `\bP-I\b` into noise. +- Exclusions go last so a later include cannot re-admit them: `--glob '!vendor/**' --glob '!node_modules/**' --glob '!.agents/notes/archived/**' --glob '!.agents/skills/dsh-trim-cot-leakage/**'` (the skill's own files quote leaked wording as calibration), plus recorded fixture and snapshot directories in scope. The [owning note](../../../notes/implemented/process/2026-08-09-committed-artifact-citations.md) also self-hits through its quoted evidence; judge it as evidence, not usage. +- Natural-language lines carry `-i` so sentence-initial capitals hit ("This PR adds…", "Probably fine…"); the first line, which matches code patterns, stays case-sensitive — `-i` would turn `\bT\d\b` and `\bP-I\b` into noise. - A zero-hit pattern proves nothing until you have seen it match: test it against a known-positive string before trusting the negative. ## English battery diff --git a/.agents/skills/record-browser-gif/SKILL.md b/.agents/skills/record-browser-gif/SKILL.md index d7df478074..3566dfb763 100644 --- a/.agents/skills/record-browser-gif/SKILL.md +++ b/.agents/skills/record-browser-gif/SKILL.md @@ -15,7 +15,7 @@ A pull request that changes product-user-visible GUI behavior MUST include a dem The recording itself is part of the evidence: use a real server booted from that pull request's branch tree, a real API key, and real model rounds. Never substitute fixture queries, mock transports, synthetic event injection, or test-only hooks unless the user explicitly asked for a fixture recording. Next to the embed, state the exact demonstrated commit SHA, the tree and origin that served it, any mode flags or browser-state exceptions, and whether a real model round ran, so reviewers know exactly what the recording proves. -## Keep the boundary explicit +## Keep recording separate from publication - Recording produces frame images and one local `.gif` artifact only; it never mutates remote state. - Publication — pushing the GIF to an assets branch and embedding it in a pull request body — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never touches the pull request's own branch. @@ -33,9 +33,9 @@ A GIF for a specific pull request demonstrates that pull request's tree, so stag ## Record the flow -1. Invoke the available browser-control skill and follow its setup, interaction, and cleanup instructions. Use the user's existing Chrome state only when requested or required; state that exception next to the GIF and do not claim fresh client state. If browser control is unavailable, use the repository-declared Playwright dependency in an isolated headless browser; do not install another driver or launch the user's browser. State that fallback next to the GIF. -2. Resolve the evidence boundary before recording: identify the exact origin, whether the app is built or in development, the transport, and any fixture or mock mode. Record only claims that the observed setup supports. -3. When a production default opens a native operating-system surface that headless automation cannot drive, select an official browser-operable production backend through the application's normal configuration. State that override next to the GIF; a fixture, mock transport, or test-only hook is not an acceptable substitute. +1. Invoke the available browser-control skill and follow its setup, interaction, and cleanup instructions. Use the user's existing Chrome state only when requested or required; state that exception in the provenance and do not claim fresh client state. If browser control is unavailable, use the repository-declared Playwright dependency in an isolated headless browser; do not install another driver or launch the user's browser. State that fallback in the provenance. +2. Before recording, identify the exact origin, whether the app is built or in development, the transport, and any fixture or mock mode. Record only claims that the observed setup supports. +3. When a production default opens a native operating-system surface that headless automation cannot drive, select an official browser-operable production backend through the application's normal configuration. State the override in the provenance; a fixture, mock transport, or test-only hook is not an acceptable substitute. 4. Choose three to six states that tell one story, such as typed, running, settled, and detail. Prefer semantic state changes over continuous capture; omit loading churn that does not help the viewer. 5. Keep one viewport and crop for every frame, and name frames lexically: `00-initial.png`, `01-typed.png`, and so on. 6. Store frames under the repository's gitignored `.playwright-mcp/` directory — browser-tool screenshots can only be written under the tool's allowed roots, and relative filenames resolve against the repository root. Create the frame subdirectory first (`mkdir -p .playwright-mcp/gif-frames-<label>`); writing into a missing directory fails with ENOENT at capture time. diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index c1a70c77da..2125ba2f36 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -109,7 +109,7 @@ function firstNonblankLine(body) { } /** - * Validate body shape and Owner against assignees. + * Validate required body sections and check Owner against assignees. * @param {{body: string, assignees: string[], allowUnassignedOwner?: boolean}} input Body input. * @returns {string[]} Validation errors. */ @@ -144,7 +144,7 @@ export function validateBody({ } /** - * Decide whether a PR has entered the human-review enforcement boundary. + * Decide whether the human-review policy applies to a PR. * @param {{isDraft: boolean, authorType: string, reviewRequestCount: number, reviewCount: number}} input PR state. * @returns {boolean} Whether the PR policy is mandatory. */ diff --git a/AGENTS.md b/AGENTS.md index 7e481c62b7..a8a202147c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,9 +97,9 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, ## Conventions - Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. -- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only shapes) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. +- ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. -- **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)). +- **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. Without a plausible relationship, an explained empty companion is correct ([package invariant rules](packages/AGENTS.md)). - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. - **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). @@ -120,7 +120,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). -- **Plan unit, e2e, and snapshot coverage** for new capability seams, lifecycle shapes, and transcript surfaces; add missing snapshot-harness support in the same change. +- **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change. - **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). - **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). @@ -134,13 +134,13 @@ Read [docs/defensive-patterns.md](docs/defensive-patterns.md) before lifecycle, Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` explains why narrowing is infeasible. Every module and export has concise JSDoc for its non-obvious contract; function-like exports include `@param`/`@returns`, as enforced by `verify-export-jsdoc`. Heritage-declared members, plugin-protocol slots, and constructors keep their docs at the declaring Service Definition, protocol, or class. -Comments and docs preserve complete contracts and non-obvious orientation, not reasoning transcripts. Do not narrate control flow or tests, preserve review history, or restate code. Keep factual clauses affecting behavior, failure, timing, ownership, or safe use; link aggressively to owning rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Wire mechanically checkable invariants into an executed top-level gate and prove each new or changed acceptance path rejects an invalid case. Use narrow justified exceptions instead of disabling a rule globally. +Comments and docs state complete contracts and context, not reasoning transcripts. Use direct, concrete terms. Do not use metaphors. Before writing `contract`, `boundary`, or `shape`, ask whether a more exact term names the subject: write `response fields`, `JSON validation`, or `ESM exports` instead of `response shape`, `validation boundary`, or `module shape`. Keep `contract` for preconditions, postconditions, invariants, compatibility promises, and other obligations that callers, callees, implementers, providers, producers, or consumers rely on. Keep a literal process, wire, security, transaction, or lifecycle boundary. Do not narrate control flow or tests, preserve review history, or restate code. Keep behavior, failure, timing, ownership, and safe-use facts; link the rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for decisions. Wire mechanically checkable invariants into an executed top-level gate and prove each changed acceptance path rejects an invalid case. Use narrow, justified exceptions instead of disabling a rule globally. -Docs accompany every code change: update affected README/JSDoc contracts together; update both sides of a bilingual pair and re-record it ([i18n contract](docs/i18n/README.md)). Current-state prose, one physical line per paragraph, one home per fact, and word budgets live in [docs/AGENTS.md](docs/AGENTS.md). +Docs accompany every code change: update affected README and JSDoc contracts together; update both sides of a bilingual pair and re-record it ([i18n contract](docs/i18n/README.md)). Current-state prose, one physical line per paragraph, one home per fact, and word budgets live in [docs/AGENTS.md](docs/AGENTS.md). ## Editing these instructions -`CLAUDE.md` symlinks `AGENTS.md` at root, `packages/`, and `examples/`; edit the real file. Keep each rule self-contained while linking high-level docs. Condense when clarity survives; raise a `verify-doc-budgets` ceiling when the contract genuinely needs more space. +`CLAUDE.md` symlinks `AGENTS.md` at root, `packages/`, and `examples/`; edit the real file. Keep each rule self-contained while linking high-level docs. Condense when clarity survives; raise a `verify-doc-budgets` ceiling when the required content genuinely needs more space. ## Vendoring policy diff --git a/README.i18n.yaml b/README.i18n.yaml index 695f81e459..35cb82e776 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: ff4cf661a060772973629844d43758d26ac1be02 -README.zh.md: a11834c590a3930e881447d79bbfeb7a10ee3a92 +README.md: 3174630d021b3868986d6ad9989d257fe8ac29fb +README.zh.md: 377ea6372a9a531c1400d08b0ef33792b452dc65 diff --git a/README.md b/README.md index ff4cf661a0..3174630d02 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ dsh plugin --profile tui add <package> # install a plugin into a custom profile dsh --profile tui # boot it ``` -The [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands. +The [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands. ### Headless diff --git a/README.zh.md b/README.zh.md index a11834c590..377ea6372a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -49,7 +49,7 @@ dsh plugin --profile tui add <package> # install a plugin into a custom profile dsh --profile tui # boot it ``` -profile 布局、层语义与配置输出命令详见 [CLI(命令行界面)约定](apps/cli/README.md#profiles)。 +profile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。 ### Headless diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 65d2716458..d61c4c545f 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -124,7 +124,7 @@ Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index f2cdeea159..a6212af84e 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -105,7 +105,7 @@ Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index 3810ec334b..f6d9a6126c 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -53,13 +53,13 @@ When a preset genuinely owns a service, wrap the provider **and every consumer t A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. That is the quietest failure here: the mount succeeds and a tool is simply missing. -Registry-shaped host capabilities need no realm at all: the host `tools` and `skills` registries are layered per scope, so rows like `skill-local` and `tool-skill` sit loose in the preset and their registrations file into this preset's layer automatically — the agent's catalog merges them with whatever the deployment registered globally. +Host capabilities exposed through registries need no realm: the host `tools` and `skills` registries are layered per scope, so rows like `skill-local` and `tool-skill` sit loose in the preset and their registrations file into this preset's layer automatically — the agent's catalog merges them with whatever the deployment registered globally. ## Verifying a change Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests. Note it shows THIS session's composition: a preset you just wrote is not mounted anywhere until a session starts on it. -To check a preset you authored, re-read the files you wrote and walk the shape: a top-level YAML list, every row a map with a `name`, every group carrying its own list, service-publishing rows behind an `isolate` realm. The settings page's preset roster runs the same shape check and marks an unloadable preset broken in red — point the user there, and ask them to start a session on the new preset to confirm the tool list; you cannot start one yourself. +To check a preset you authored, re-read the files and validate these fields: the top level is a YAML list, every row is a map with a `name`, every group carries its own list, and service-publishing rows sit behind an `isolate` realm. The settings page's preset roster validates the same fields and marks an unloadable preset broken in red — point the user there, and ask them to start a session on the new preset to confirm the tool list; you cannot start one yourself. `cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 66407faf1d..f2f0122948 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -117,7 +117,7 @@ Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index cb7b0a1ebf..4b5aed6cd2 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 40807da0b1a88708e2e5e9efa3569bd0d6f6a020 +README.md: 0b5faf8993cd8065fffcfec5f240b0084508db91 README.zh.md: b9c48c16dd4be186266d30a438329463c31aca70 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 40807da0b1..0b5faf8993 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -61,7 +61,7 @@ New sessions default to the `workspace-write` permission preset. Bash and filesy `DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional RL-compatible `--patch` overlay that pins native mode, renders only `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.` as the system prompt, disables Workspace instructions and every Web runtime prompt contribution, and exposes only persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition. -`DSH_SYSTEM_PROMPT` is passed as the system-prompt [`persona`](../../../packages/core/system-prompt/README.md#config): complete `{{…}}` groups use that contract's strict variable interpolation and have no literal-brace escape; any set value, including an empty string, is authoritative and an empty value therefore removes the system prompt, while only an unset variable selects the fallback. +`DSH_SYSTEM_PROMPT` is passed as the system-prompt [`persona`](../../../packages/core/system-prompt/README.md#config): complete `{{…}}` groups use that contract's strict variable interpolation rules and have no literal-brace escape; any set value, including an empty string, is authoritative and an empty value therefore removes the system prompt, while only an unset variable selects the fallback. ## Shared deployment behavior diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index e2e8a89980..97f5222398 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -138,7 +138,7 @@ Examples: resolved = { mode: 'profile', profile, patches } }) - /** Reject parent options that crossed a subcommand boundary. */ + /** Reject parent options supplied before a subcommand. */ const rejectParentOptions = (command: string): void => { const parent = program.opts<{ profile?: string diff --git a/apps/cli/src/process-shutdown.ts b/apps/cli/src/process-shutdown.ts index 7ef9d32632..e34f85f7f3 100644 --- a/apps/cli/src/process-shutdown.ts +++ b/apps/cli/src/process-shutdown.ts @@ -14,8 +14,8 @@ export interface ProcessShutdown { /** * Create one process-exit controller around an application disposer. * @param dispose - Whole-application teardown that resolves at quiescence. - * @param forceExit - Forced process exit boundary, replaceable by tests. - * @param complete - Natural process completion boundary, replaceable by tests. + * @param forceExit - Function that exits the process immediately, replaceable by tests. + * @param complete - Function that records the natural completion code, replaceable by tests. * @param timeoutMs - Grace before forced exit, replaceable by tests. * @returns A controller whose normal calls coalesce and whose repeated signal call escalates. */ diff --git a/apps/cli/tests/memory-mcp-configs.spec.ts b/apps/cli/tests/memory-mcp-configs.spec.ts index 7069b22738..818b8f4561 100644 --- a/apps/cli/tests/memory-mcp-configs.spec.ts +++ b/apps/cli/tests/memory-mcp-configs.spec.ts @@ -1,6 +1,6 @@ /** * The third-party memory examples stay config-only. This suite parses every - * checked-in overlay, verifies its pin/transport/secret boundary, then replaces + * checked-in overlay, verifies its package pin, transport, and secret handling, then replaces * only the upstream endpoint with the package-owned keyless MCP fixture and * proves the real Cordis Loader discovers a tool through the generic bridge. */ @@ -81,7 +81,7 @@ async function waitForTool(ctx: Context, name: string): Promise<void> { } describe('third-party memory MCP example overlays', () => { - it.each(examples)('parses $file with the documented generic boundary', (contract) => { + it.each(examples)('parses $file with the documented generic plugin fields', (contract) => { const file = resolve(exampleDir, contract.file) const source = readFileSync(file, 'utf8') const row = insertedRow(loadOverlayPatches('memory-mcp-config-test', file)) diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts index 6ce11dc7f0..bd2268231b 100644 --- a/apps/cli/tests/source-launch.compat.spec.ts +++ b/apps/cli/tests/source-launch.compat.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' /** * Keyless smoke for the SOURCE `dsh` launcher: run `apps/cli/src/bin.ts` * with the exact production launch vector (`node --import tsx/esm`, the same - * shape as `bin/dsh` and the root `dsh`/`demo:web` scripts) and assert the + * executable and arguments as `bin/dsh` and the root `dsh`/`demo:web` scripts) and assert the * required-config diagnostic. The Node compatibility matrix runs this * WHOLE file, so a Node release changing module hooks or TypeScript handling * breaks this gate instead of every developer's `pnpm dsh`; the built-bin diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts index 9911742633..e57367ee46 100644 --- a/apps/web/tests/approval-composer.e2e.ts +++ b/apps/web/tests/approval-composer.e2e.ts @@ -35,9 +35,9 @@ const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const MODE = webSnapshotMode() // Irreducible payload: the command has to be long enough to pass the card's -// height cap, which is the only shape that reproduces an action row pushed off -// screen. Unrelated tokens, not a repeated word — the model compresses a -// repeated word into `printf 'alpha %.0s' {1..400}` when recording, and a +// height cap, which is the only command length that reproduces an action row pushed off +// screen. Unrelated tokens, not a repeated word — a repeated word is what the +// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a // short command proves nothing here. The formula keeps the source small; the // model receives the expanded literal it has to put in the command. const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ') diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index ad9d6a6874..aca85146b5 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -186,7 +186,7 @@ describe('web e2e: long Chat interaction contract', () => { const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => ( event.type === 'turn/end' && event.data.turn === BRANCH_TURN )) - if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no completed boundary`) + if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no turn/end event`) const expectedUserText = textContent(branchUserEvent.data.content) await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100) diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts index 2daed2c0f6..d3c27f0ce0 100644 --- a/apps/web/tests/complex-history.perf.ts +++ b/apps/web/tests/complex-history.perf.ts @@ -1,7 +1,7 @@ // Opt-in browser benchmark for high-cardinality workspace and history // rendering. It reports measurements without timing assertions because host -// speed is not a correctness contract; structural assertions keep the load -// shape from silently shrinking. +// speed is not a correctness contract; structural assertions keep the number +// of workspaces and history entries from silently shrinking. import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts index 687419dc53..0ec3ef63e1 100644 --- a/apps/web/tests/composer-draft-scroll.e2e.ts +++ b/apps/web/tests/composer-draft-scroll.e2e.ts @@ -64,12 +64,12 @@ const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => { }).join('\n') /** - * A draft ending in a newline: the shape where the two layers reserve their + * A draft ending in a newline, where the two layers reserve their * final line box on different terms. A textarea keeps one for the caret after a * final newline; `white-space: pre-wrap` collapses a text node's trailing * newline and generates none. The hidden auto-grow mirror carries the newline * and so decides the height for both, which is why the backdrop needs no - * padding of its own — but only a draft of this shape can show it. + * padding of its own — but only a draft with a trailing newline can show it. */ const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n` @@ -381,7 +381,7 @@ describe('web e2e: composer draft scrolling', () => { const data = new DataTransfer() data.setData('text/plain', text) el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) - // Ending in a newline is the shape the engines disagree on: the caret + // The engines disagree when the draft ends in a newline: the caret // lands on a line with nothing on it, where chromium reports no client // rects at all for the collapsed position. }, `\n${DRAFT}\n`) @@ -401,7 +401,7 @@ describe('web e2e: composer draft scrolling', () => { it('a draft ending in a newline scrolls to its true end, not a line above it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline')) - // The layers reserve a final line box on different terms, so this shape is + // The layers reserve a final line box on different terms, so the trailing-newline case is // the one that separates a height every layer agrees on from a box measured // one line short of the caret's own last position. const input = page.locator('textarea:enabled').first() @@ -453,7 +453,7 @@ describe('web e2e: composer draft scrolling', () => { const data = new DataTransfer() data.setData('text/plain', text) el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) - // The ordinary shape — not ending in a newline — so the collapsed branch + // The ordinary case, without a trailing newline, so the collapsed branch // of the reveal keeps a real engine under it; the case above owns the // after-newline branch. }, `\n${DRAFT}`) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index c88a823fb7..7f507c59d2 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -42,7 +42,7 @@ function appFrame(page: Page) { return page.locator('[style*="grid-template-columns"]').first() } -/** Render the two boundary affordances without platform-dependent coordinates. */ +/** Render the two column-resize handles without platform-dependent coordinates. */ async function handleSnapshot(page: Page): Promise<string> { const handles = await page.locator('[class*="handle"]').evaluateAll(elements => elements.map(element => ({ diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 9abc8b4b6d..b96a1fa393 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -164,8 +164,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { sessionId = await settled } await recordFixture(scaffold, sessionId!, SEED) - // Fixture honesty: the recording must carry the shape the replay - // scenarios assert on — three calls in turn 1 and two closed turns. + // Fixture honesty: the recording must contain the events the replay + // scenarios assert on: three calls in turn 1 and two closed turns. const recorded = parseSessionLog(await readFile(SEED, 'utf8')) expect(recorded.filter(e => e.type === 'turn/end')).toHaveLength(2) const calls = recorded.filter((e): e is SessionEvent & { data: { name: string } } => e.type === 'tool/call') diff --git a/apps/web/tests/pwsh-terminal.e2e.ts b/apps/web/tests/pwsh-terminal.e2e.ts index e1b92ed2e3..85b52a734f 100644 --- a/apps/web/tests/pwsh-terminal.e2e.ts +++ b/apps/web/tests/pwsh-terminal.e2e.ts @@ -1,7 +1,7 @@ // Keyless browser regression for pwsh UI parity with bash: a seeded session // whose pwsh call/result is presented by the REAL tool-pwsh on replay (the // api-proxy recomputes presentation views from logged args/result content) -// must render as a bash-shaped terminal card with the parsed exit-status +// must render with the same terminal card layout as bash and show the parsed exit-status // pill — not a generic console-fenced card. The seed is authored, not // recorded: its header line carries no `cwd` // field (seedSession writes the session cwd itself, and a Windows temp path @@ -43,7 +43,7 @@ const HAS_PWSH = MODE === 'record' ? false : spawnSync( { encoding: 'utf8' }, ).status === 0 -describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls render as bash-shaped terminal cards', () => { +describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bash terminal-card layout', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -75,7 +75,7 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls render as b await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1) await result.click() await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 15_000 }) - // The tool row is expand-gated: the settled bash-shaped row carries the + // The tool row is expand-gated: the settled row uses the bash layout and carries the // shell-family variant, and the terminal card lives in the expanded body. const row = page.locator('[data-tool="pwsh"]').first() await row.waitFor({ timeout: 15_000 }) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 6f865567bb..5e16453a53 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -31,7 +31,7 @@ const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md') const MODE = webSnapshotMode() // The options carry long descriptions on purpose: the squeeze assertion below -// needs option copy that WRAPS, which is the only shape that reproduces a +// needs option copy that WRAPS, which is the only text layout that reproduces a // collapsed row painting its copy outside its own box. const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' @@ -116,7 +116,7 @@ describe('web e2e: resident question composer round trip', () => { return { rows: rows.length, spill: Math.max(...spill), - // Wrapped copy is the shape that overflows a collapsed row, and a + // Wrapped option text is what overflows a collapsed row, and a // scrolling list proves the seat is genuinely capped. Without both, // the spill assertion would hold vacuously. wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length, diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 0c5524fc42..eebdd3a8a8 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -443,7 +443,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We let replayHandle: ReplayHandle | undefined try { process.chdir(workspaceCwd) - // The production resolution shape: an empty profile root inside the temp + // The production module-resolution setup: an empty profile root inside the temp // harness home, with bare plugin names resolving through the flat module // fallback the launcher heals under <home>/profiles. healProfilesModuleFallback(INSTALL_ANCHOR, harnessHome) @@ -621,7 +621,7 @@ export function fixtureUserPrompts(fixtureText: string): string[] { * through the REAL backend API (throwaway Context + SessionStore + JSONL * plugin — the semantic-checkpoint precedent), never raw file writes: no * knowledge of bucket hashing, filename encoding, or compression, and - * malformed shapes fail loud at seed time. The fixture's tokenized identity + * malformed session events fail loud at seed time. The fixture's tokenized identity * ({{sessionId}}/{{cwd}}) is realized for this world before parsing. * @param scaffold - the target scaffold. * @param fixtureText - raw recorded session.jsonl contents. @@ -725,7 +725,7 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string { // between local worktrees and CI scratch directories. .replace(/(Compacted \d+ history items \(~)\d+( tokens\))/g, '$1{{tokens}}$2') // Message IconActions clocks widen by calendar day/year; collapse every - // shape so goldens stay stable across midnight and year boundaries. + // format so goldens stay stable across midnight and year changes. .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') .replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') .replace(/(?<!\d)\d{1,2}:\d{2}:\d{2}(?:\.\d+)?(?:\s*[AP]M)?(?!\d)/gi, '{{clock}}') diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts index 8ac775be1d..3e94faa16d 100644 --- a/apps/web/tests/search-card.snapshot.ts +++ b/apps/web/tests/search-card.snapshot.ts @@ -4,7 +4,7 @@ // FixtureApiClient transport (no API key, no model round), opens the fixture // session, and pins the search card the `grep` turn (fixture turn 67) renders in // the assembled application. The built-boot smoke proves the graph boots but -// carries no behavior assertions by contract; this is the assembled-output check +// intentionally carries no behavior assertions; this is the assembled-output check // that a broken SearchRow registration or a dropped card would fail — the // per-package suites bench over src and cannot see the bundled wiring. // @@ -13,7 +13,7 @@ // fixture, not harvested from a live model. The recovery-footer arm is a pure // derivation over the result view, pinned at every render site by the // ui-conversation suite; here the fixture turn exercises the assembled card -// shape and its cap. +// fields and its cap. import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -24,7 +24,7 @@ const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/search-card/grep- installAssembledBootEnv() -/** Normalize a rendered search card to a stable text shape: the kind, the banner +/** Normalize a rendered search card to stable text fields: the kind, the banner * summary, each file header (path + count), each visible match line, the expand * control label, and the recovery footer. */ function cardShape(root: Element): string { @@ -63,7 +63,7 @@ describe('assembled search card', () => { }, { timeout: 10_000 }) // `data-tool` sits on the ToolRow root; the collapsed row is the expand - // toggle. Click it so the card and its recovery footer mount, then shape the + // toggle. Click it so the card and its recovery footer mount, then serialize the // whole row (the card lives inside ToolRow's body wrapper). const grepRow = document.querySelector('[data-tool="grep"]')! act(() => { fireEvent.click(grepRow.querySelector('[data-expandable]') ?? grepRow) }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 257da0031a..a112933f9c 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -102,7 +102,7 @@ function withCompaction(raw: string, meter: TokenMeterService): string { }) // Load-bearing exactness: the projections subtract this count verbatim, so // it must equal what the host's fold prices for these nodes. The estimator - // prices message CONTENT only, so a minimal wrapper per storage shape is + // prices message CONTENT only, so a minimal wrapper for each stored event format is // exact — pre-identity rows carry bare `content` (the persistence read path // upgrades them), a current row carries the full `message` envelope. const priceRow = (row: (typeof events)[number]): number => { @@ -169,7 +169,7 @@ function withCompaction(raw: string, meter: TokenMeterService): string { }, }) // The persistence seed helper requires a terminal turn/end. Keep the manual - // command standalone, then add a closed zero-step fixture boundary after it. + // command standalone, then add a closed zero-step turn after it. const closureTurn = lastTurn + 1 at({ type: 'turn/start', data: { turn: closureTurn } }) at({ type: 'turn/end', data: { turn: closureTurn, reason: { kind: 'completed' } } }) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 5d929044b3..25d1b2c0dc 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -90,7 +90,7 @@ it('assembles the shipped Web catalog with the confined access default', async ( // `workspace-write` is not "the workspace and nothing else": the shared roots // helper always admits the temp directories too. Pinning it against an // explicit mode keeps the claim independent of this surface's default, and - // keeps a future boundary test from being run inside /tmp — where an + // keeps a future sandbox-confinement test from being run inside /tmp — where an // "escape" write succeeds by design and reads as a sandbox failure. expect(writableRoots(scaffold.ctx.sandboxPolicy.resolve({ mode: 'workspace-write' }))).toEqual( expect.arrayContaining([canonicalPath('/tmp'), canonicalPath(tmpdir())]), diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index bf5f920458..bbb064c119 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -17,11 +17,10 @@ // replacing those nodes. // // The round-trip against a loopback host is far too fast to observe, so this -// scenario HOLDS the `session.history` response open at the browser's network -// boundary and asserts the visible frame while it is in flight. That gate is -// what makes the assertions non-vacuous: without the phase exemption, the -// held window is exactly when `settling` would be painted and the composer -// hidden. +// scenario HOLDS the `session.history` response open in the browser's network +// handler and asserts the visible frame while it is in flight. That wait is +// what makes the assertions non-vacuous: without the phase exemption, the held +// window is exactly when `settling` would be painted and the composer hidden. // // Zero model calls: registering a workspace and opening its blank session are // host RPCs with no model involvement. A stray stream would fail loud with diff --git a/apps/web/tests/todo-row.snapshot.ts b/apps/web/tests/todo-row.snapshot.ts index ff43cee39b..8186905f62 100644 --- a/apps/web/tests/todo-row.snapshot.ts +++ b/apps/web/tests/todo-row.snapshot.ts @@ -20,7 +20,7 @@ const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/todo-row/parallel installAssembledBootEnv() -/** Normalize the todo row and the plan strip to a stable text shape: the row's +/** Normalize the todo row and the plan strip to stable text fields: the row's * title, its truncatable summary, its non-shrinking suffix, then the panel's * per-status header and every list item with its status. */ function todoShape(row: Element, panel: Element): string { diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index d68aef4700..4822a254cd 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -1,7 +1,7 @@ // Web e2e scenario: assistant IconActions belong to the settled answer, so // they arrive with `turn/end` and not before. The recorded turn narrates in -// plain text before its tool call, which is the shape that would hand the -// footer to mid-turn narration for the seconds a tool runs and then move it +// plain text before its tool call, which is the event order that would show the +// footer beside mid-turn narration for the seconds a tool runs and then move it // down. A `hang` sidecar on the SECOND model call parks the turn after the // narration and the tool result are durable, so the running state is stable by // construction rather than by timing; stopping from that park writes the diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index a8e238422b..c18a522d30 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -31,8 +31,8 @@ const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', impo const MODE = webSnapshotMode() const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md') const SEED_ID = 'workspace-management-web-e2e' -// Both waits exceed ui-primitives' 200ms POINTER_GRACE_MS. Keep them coupled -// to that contract if the shared grace tuning changes. +// Both waits exceed ui-primitives' 200ms POINTER_GRACE_MS. Keep them above +// that value if the shared setting changes. const POINTER_TRANSIT_MS = 300 const POINTER_HOLD_MS = 600 diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ec34ec435a..a8323cdd78 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -31,7 +31,7 @@ function rejectStandaloneServe(): Plugin { * editing shell code re-hashes only index and returning clients keep the * cached vendor chunk. * - * Boundary invariant: every member must be react-free. A package that + * Every member must be React-free. A package that * imports react/jsx-runtime must never be listed — rollup folds a module * shared between the entry and a manual chunk into the manual chunk, so one * react-importing member would drag the single shared react copy into @@ -77,8 +77,8 @@ const BOOT_GRAMMAR_FILES: readonly string[] = [ const FONT_EXTENSIONS: readonly string[] = ['.woff2', '.woff', '.ttf'] /** - * npm package name of a resolved module id (the segment after the LAST - * `node_modules/` — pnpm nests the real package under an inner node_modules). + * npm package name of a resolved module id: the segment after the last + * `node_modules/`. pnpm nests the real package under an inner node_modules. */ function npmPackageOf(id: string): string | undefined { const parts = id.split('/node_modules/') diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 04cd0f22f0..64ea632cb6 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -6,7 +6,7 @@ This file defines document structure, Markdown tiers, writing rules, and `verify These rules apply to human-facing documentation; [Agent Notes](../.agents/notes/README.md) remain outside their scope. A [postmortem](postmortem/README.md) is an incident-scoped reference; chronology records evidence, not a teaching sequence. A document's subject and tree position fix its scope: describe its own subject at appropriate detail and direct children only by purpose, responsibility, and high-level behavior; link to the owning descendant for lower-level detail. Document type does not widen that scope. A reference may be exhaustive only about its own subject. Testing mechanisms, fixtures, and harnesses belong at the lowest owning level; higher documents link there. -Classify every in-scope document as a tutorial or reference. A tutorial follows an ordered path to an outcome and introduces only what each step needs. A reference defines a lookup scope and describes current behavior without depending on a teaching sequence. Separate substantial tutorial and reference content; use a clear structural boundary when either part is small. +Classify every in-scope document as a tutorial or reference. Tutorials follow an ordered path to an outcome and introduce only what each step needs. References define a lookup scope and current behavior without a teaching sequence. Separate substantial tutorial and reference content; label a section when either part is small. Before writing a tutorial, privately classify the reader's starting knowledge and each concept as beginner, intermediate, or advanced. Establish prerequisites before dependent concepts, increase difficulty gradually, and move unnecessary advanced material to a later tutorial or reference. @@ -20,18 +20,18 @@ Each fact has one home: the tier whose job it is; elsewhere, link there. |---|---|---| | Root `AGENTS.md` | Standing orders: rules an agent needs in context in every session, one to three lines each, linking its home | Stories, worked examples, situational procedures, anything restated from a linked home | | Subtree `AGENTS.md` (`packages/`, `examples/`, `docs/`, `.agents/notes/`) | Orders specific to that subtree | Repo-wide rules the root file already carries | -| [architecture.md](architecture.md) | System map: services, loop, capability seams, extension points — read before changing `packages/` | Type shapes (→ subsystems), per-package detail (→ package READMEs), decision rationale (→ Agent Notes), implementation-status annotations | -| [subsystems/](subsystems/README.md) | One reference page per subsystem: type shapes, semantics, and the generated Cordis surface | Behavior narration (→ architecture.md) | -| [Agent Notes](../.agents/notes/README.md) | Active decision records: the why, what-was-given-up, and verification contract; `implemented/` notes describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped; archived notes are frozen history, never current authority | +| [architecture.md](architecture.md) | System map: services, loop, capability seams, extension points — read before changing `packages/` | Type definitions (→ subsystems), per-package detail (→ package READMEs), decision rationale (→ Agent Notes), implementation-status annotations | +| [subsystems/](subsystems/README.md) | One reference page per subsystem: type definitions, semantics, and the generated Cordis API | Behavior narration (→ architecture.md) | +| [Agent Notes](../.agents/notes/README.md) | Active decision records: the why, what-was-given-up, and required verification; `implemented/` notes describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped; archived notes are frozen history, never current authority | | [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — | | [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the Agent Note each guide links) | | [user/](user/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history | | Package README | The per-package contract: config, semantics, limitations, extension points, and [Model Experience](cookbook/adding-a-package.md#4-write-the-package-readme) | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns | -| [development.md](development.md) | First-stop contributor onboarding: local setup, daily workflow, and CI shape at summary level; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ Agent Notes), gate-by-gate enumerations that drift from `package.json` scripts | +| [development.md](development.md) | Contributor setup, daily workflow, and a summary of CI; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ Agent Notes), check-by-check lists that drift from `package.json` scripts | | Generated reference: the per-page `cordis-surface` regions in [subsystems/](subsystems/README.md), the [Cordis core API + inherited tier](cordis-api/context.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive English sources regenerated from source and freshness-gated; reviewed Chinese counterparts follow the [pairing workflow](i18n/README.md#scope-and-exclusions) | Hand edits to generated English sources or regions; Chinese counterparts update through pairing only | | Skills (`.agents/skills/`) | Reusable workflows and specialized decision standards | Product and runtime contracts (→ docs or source) | -Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookbooks; type shapes → subsystems; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link. +Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookbooks; type definitions → subsystems; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link. ## Writing rules @@ -41,8 +41,8 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The owning [subsystems page](subsystems/README.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types; a type is documented on its declaring package group's page ([page scoping](../.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md)). - **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). -- **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, timing, modality, exceptions, consequences, and non-obvious orientation; delete narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link its rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for details. -- Write directly: name actors and facts plainly ([decision](../.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)). Reserve `seam` for the defined capability; avoid metaphorical "gate", "vocabulary", and "surface". +- **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, failure, timing, ownership, modality, exceptions, consequences, and non-obvious orientation; delete narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link its rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for details. +- Write directly: name actors and facts ([decision](../.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)). Reserve `seam` for the defined capability. Name the exact check, type, API, operation, or behavior instead of metaphorical "gate", "vocabulary", or "surface". ## Wordcount Budgets @@ -54,7 +54,7 @@ When the gate goes red: 2. **Condense** content that belongs here but can be shorter. 3. **Raise** the ceiling only when the words need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug. -Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the contract still has room, and raise it when content would otherwise be deleted. Targets: root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 650 and this file ≤ 1,250; `packages/README.md` ≤ 600. Review governs unbudgeted tiers. +Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room, and raise it when content would otherwise be deleted. Targets: root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 650 and this file ≤ 1,250; `packages/README.md` ≤ 600. Review governs unbudgeted tiers. ## The slop checklist diff --git a/docs/agent-lifecycle.i18n.yaml b/docs/agent-lifecycle.i18n.yaml index 144d77b8c2..97dd2649f0 100644 --- a/docs/agent-lifecycle.i18n.yaml +++ b/docs/agent-lifecycle.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/agent-lifecycle.md -agent-lifecycle.md: 7e3939b7fd6e7730918e4bc05e5bfcd655422e04 -agent-lifecycle.zh.md: 259ec78431ff0ead61c66e1d8edae4d22f1bcfd0 +agent-lifecycle.md: 4d89e591626454af72de40426e66248f9b6fa404 +agent-lifecycle.zh.md: a54547f486c447fe83fbe6017d016102d66b8612 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 7e3939b7fd..4d89e59162 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -75,8 +75,8 @@ The `assistant/message` event records every successful provider call, including `dsh-compact-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. -The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later boundary claims their next-step batch. +The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch. -SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors. +SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request construction, steering, continuation, and errors. Maintenance mode: curated Mermaid sequence; exact event signatures live in the generated Cordis catalog. diff --git a/docs/agent-lifecycle.zh.md b/docs/agent-lifecycle.zh.md index 259ec78431..a54547f486 100644 --- a/docs/agent-lifecycle.zh.md +++ b/docs/agent-lifecycle.zh.md @@ -77,8 +77,8 @@ sequenceDiagram `dsh-compact-basic` 在派生请求之前通过 `agent/pre-step` 处理压力,而 `agent/request-error` 仅用于规范的上下文溢出。任一触发条件满足后,系统都会先执行可选的工具结果剪枝,再选择摘要。恢复发生在失败步骤结束之后、失败轮次结束之前;只有当剪枝或摘要生成推进了 surface replacement generation 时,系统才会开启一个全新的重试轮次,否则仍以原始请求错误为准。 -以返回的 `agent/pre-step` 决策为准;通过包装 `next()` 的监听器会保留下游消息,除非有意替换这些消息。steering(中途引导)和注入的上下文在后续边界认领其下一步骤批次后,会经过同一 waterfall(瀑布式事件)。 +以返回的 `agent/pre-step` 决策为准;通过包装 `next()` 的监听器会保留下游消息,除非有意替换这些消息。steering(中途引导)和注入的上下文在后续的认领操作取得其下一步骤批次后,会经过同一 waterfall(瀑布式事件)。 -需要可回放 transcript(文本记录)数据的 SDK 用户应当消费 `session/event`;`agent/*` 是用于队列与状态、提示词拦截、请求整形、steering、继续执行和错误处理的实时协调接口。 +需要可回放 transcript(文本记录)数据的 SDK 用户应当消费 `session/event`;`agent/*` 是用于队列与状态、提示词拦截、请求构造、steering、继续执行和错误处理的实时协调接口。 维护模式:英文源文件包含人工维护的 Mermaid 时序图,并由生成器写出;本中文文件作为经评审对侧通过双语配对维护。确切的事件签名位于生成的 Cordis 目录中。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 0d82ebdd37..29bfed3f19 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 1fdbe256afb7e870e953f05bd29923a4e1c7c22b -api-gateway.zh.md: b9e18bf78bb5887725a5278e0b28efb86d17b944 +api-gateway.md: 4b904f24e5755460b6c629e3d2fd43ffc1aefaaf +api-gateway.zh.md: 1e434cbb99450d241d6fde7e570ae1fadf5c0209 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 1fdbe256af..4b904f24e5 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -85,7 +85,7 @@ The `api-remotes` assembly and the `ctx.remote` contract are React-independent; | Build | `@deepseek-ai/dsh-typert-generator` | Strictly analyzes Remote signatures, the type graph, lookups, Contexts, and source locations from the Host `ts.Program`, then generates Host and Host-for-Client artifacts | | Host | `@deepseek-ai/dsh-typert-registry` and Loader | Places generated Host descriptors, schemas, and business-package registrations in `ctx.typert`, and holds lookup and Context providers | | Host | `@deepseek-ai/dsh-api-remotes` | Owns the application Agent/Session identity policy and configures the corresponding TypeRT lookups | -| Host | `@deepseek-ai/dsh-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates boundaries | +| Host | `@deepseek-ai/dsh-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates request and return values | | Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.remote` and `remote.<namespace>` child Services, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | | Client | `@deepseek-ai/dsh-api-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code | | Both | `@deepseek-ai/dsh-client-connection` | Provides the RPC carrier, request correlation, trust boundary, cancellation, response envelope, and the `/api` HTTP bridge | @@ -114,7 +114,7 @@ Business packages expose the Host Loader entry through `./typert` and the Host-f Parameter names in Remote Client declarations come from wire fields, while parameter and return types reference Client-safe types exported by the original business package. The declaration map resolves the generated property behind `ctx.remote.goals.create` back to the Host source method marked with `@Remote`, so editors that support declaration maps can navigate from a Client call to the real implementation instead of stopping at the generated `.d.ts`. -Strict analysis requires a Remote to be a public, non-static instance method with a concrete implementation. The method cannot be generic; parameters must be required, named simple identifiers and cannot use destructuring, default values, rest parameters, or optional parameters. TypeRT generates strict schemas for ordinary JSON-representable types; complex objects such as workspace classes must have a unique `TypeRTLookupMap` declaration. Lookup and Context packages are responsible for both static declaration merges and runtime provider registration; if either side is missing, the build or earliest resolvable runtime boundary fails. +Strict analysis requires a Remote to be a public, non-static instance method with a concrete implementation. The method cannot be generic; parameters must be required, named simple identifiers and cannot use destructuring, default values, rest parameters, or optional parameters. TypeRT generates strict schemas for ordinary JSON-representable types; complex objects such as workspace classes must have a unique `TypeRTLookupMap` declaration. Lookup and Context packages are responsible for both static declaration merges and runtime provider registration; if either side is missing, the build fails or the first call that needs the provider fails. ## Runtime invocation @@ -122,7 +122,7 @@ Remote and API Proxy share the Connection's `/api` route. The Client Remote call The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler in interceptor order. The TypeRT Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; unclaimed requests fall back to the existing API Proxy. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier in the future does not require changes to Remote descriptors or the Client programming interface. -For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails at the boundary before entering or after leaving business code. +For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `lookup-unavailable`, and unloading the configuration restores the provider's default policy. API Remotes owns the standard `agentFor()` semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. The Web API Proxy supplies its Agent defaults and scope setup, then consumes the same resolver for legacy methods. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index b9e18bf78b..1e434cbb99 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -85,7 +85,7 @@ Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导 | 构建 | `@deepseek-ai/dsh-typert-generator` | 从 Host `ts.Program` 严格分析 Remote 签名、类型图、lookup、Context 与源码位置,并生成 Host 和 Host-for-Client 产物 | | Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 | | Host | `@deepseek-ai/dsh-api-remotes` | 负责应用的 Agent/Session 身份策略,并配置对应的 TypeRT lookup | -| Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service 并校验边界 | +| Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service,并校验请求值和返回值 | | Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.remote` 与 `remote.<namespace>` 子 Service,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | | Client | `@deepseek-ai/dsh-api-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | | 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与 `/api` HTTP bridge | @@ -114,7 +114,7 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则引用原业务包导出的 Client-safe 类型。声明 map 把 `ctx.remote.goals.create` 最终解析到的生成属性映射到带 `@Remote` 的 Host 源方法,因此支持 declaration-map 的编辑器可以从 Client 调用跳到真实实现,而不是停在生成的 `.d.ts`。 -严格分析要求 Remote 是公开、非静态、有具体实现的实例方法。方法不能是泛型;参数必须是具名且必填的简单标识符,不能使用解构、默认值、rest 或可选参数。可 JSON 表示的普通类型由 TypeRT 生成严格 schema;工作区 class 等复杂对象必须具有唯一的 `TypeRTLookupMap` 声明。lookup 与 Context 包同时负责静态声明合并和运行时提供方注册,缺少任一侧都会在构建或最早可解析的运行时边界报错。 +严格分析要求 Remote 是公开、非静态、有具体实现的实例方法。方法不能是泛型;参数必须是具名且必填的简单标识符,不能使用解构、默认值、rest 或可选参数。可 JSON 表示的普通类型由 TypeRT 生成严格 schema;工作区 class 等复杂对象必须具有唯一的 `TypeRTLookupMap` 声明。lookup 与 Context 包同时负责静态声明合并和运行时提供方注册;缺少任一侧都会导致构建失败,或者首次调用需要该提供方时失败。 ## 运行时调用 @@ -122,7 +122,7 @@ Remote 与 API Proxy 共用 Connection 的 `/api` 路由。Client Remote 调用 Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内按 interceptor 顺序分发。TypeRT Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;未认领的请求回退到既有 API Proxy。Connection 拥有传输、RPC id、响应 envelope 和 request cancellation,Gateway 只拥有 Remote 数据协议和业务分发。未来替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程界面。 -Gateway 每次调用都从当前注册表解析描述符和实时 Service,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context provider 解析对象或接收者,最后调用 binding 指向的 Service 方法并校验返回值。缺少 provider、identity 未命中、binding 不一致、参数多缺、schema 失败和方法不存在都在进入或离开业务边界时失败。 +Gateway 每次调用都从当前注册表解析描述符和实时 Service,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context provider 解析对象或接收者,最后调用 binding 指向的 Service 方法并校验返回值。缺少 provider、identity 未命中、binding 不一致、参数多缺、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 lookup provider 的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于 provider 挂载;没有 provider 时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复 provider 默认策略。API Remotes 负责 `agent` 与 `session` 的标准 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。Web API Proxy 提供 Agent 默认值与 scope 设置,再让旧方法使用同一个 resolver。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 22c74a441e..344bac8145 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: ebf05397cb67cea336dd36a4d9416d43b6002d4e -architecture.zh.md: fec9a00484c495b0eed4773f262bb44543e304bd +architecture.md: 8d1c5a1be391e2455aefc69db89d96027aaf3efa +architecture.zh.md: 53bf9f54a5503ae40aa62a348822f9d92162dda2 diff --git a/docs/architecture.md b/docs/architecture.md index ebf05397cb..8d1c5a1be3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -122,7 +122,7 @@ Pruning precedes summaries; overflow retries require durable progress. `agent/re Adapter selection, dispatch, and iteration failures become terminal error or aborted `finish` chunks. `agent/request-error` receives request coordinates, normalized `LlmFailure`, available retry policy, and signal; middleware and consumer errors remain outside recovery. Failed chunks commit neither messages nor tool calls. -Other failures use `agent/error`; cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` reports its cause before clearing and aborting; idle calls emit nothing. Waking input that lands after the abort fires but before convergence runs at the driver's convergence boundary, while a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). Durability distinguishes `aborted` cancellation from `disposed` teardown, which awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +Other failures use `agent/error`; cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` reports its cause before clearing and aborting; idle calls emit nothing. The driver processes waking input received after abort starts but before convergence; a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). Durability distinguishes `aborted` cancellation from `disposed` teardown, which awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). Turn and step events are turn-enclosed; the loop appends `user/message` events only from entered batches inside a turn. A turn opens before the initial claim and pre-step, so rejection, empty input, cancellation, or failure closes a durable turn without any step events. Standalone `compact/* { turn: null }` events consume no turn, and their lock-time markers may interleave with inbox splices. Reload synthesizes interrupted turn ends; `session/end-seed` distinguishes stale compaction orphans from live locks. After close, only `agent/error` reports failures. Each turn has one [TurnEndReason](subsystems/session.md#why-a-turn-ended-turnendreasonmap). @@ -142,7 +142,7 @@ The session log is authoritative. `deriveMessages()` projects model history; raw **Model-visible ⟺ logged**: messages entering at `step/start` plus the folded `request/header` reconstruct every request. The header marks adapter defaults so later proposals discard them and re-resolve the route without losing explicit settings. `request/context` separately records registration-bound provider, model, and capacity metadata when the route changes; it does not participate in request reconstruction or header equality. `dsh-agent-loop/invariant` asserts reconstructability through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). -Durability is a plugin concern. Backends copy synchronous `session/event` notifications into fixed-window durable batches; `session/flush` bypasses the wait before requests and top-level tool dispatch, and after `turn/end` before another turn or idle. `SessionPersistence` stores events and header metadata; JSONL defaults to checksummed Zstandard and SQLite shares the contract ([checkpoint decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md), [batching decision](../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)). +Durability is a plugin concern. Backends copy synchronous `session/event` notifications into fixed-window durable batches; `session/flush` bypasses the wait before requests and top-level tool dispatch, and after `turn/end` before another turn or idle. `SessionPersistence` stores events and header metadata; JSONL defaults to checksummed Zstandard, and SQLite uses the same checkpoint and batching rules ([checkpoint decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md), [batching decision](../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)). Between turns, owners append log-only events through `Session`, flushing only for durability. `session/title` relies on bounded background persistence and lifecycle drains; manual compaction flushes its bracket before the operation completes. Title work never delays responses; the latest title event wins, and it records the source message seqs and whether the user, fallback, or provider supplied it. Title records are inherited fork boundaries ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). @@ -185,7 +185,7 @@ New behavior attaches to a documented extension point; a loop change updates thi | Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it | | Add filesystem access or policy | implement a `ctx.fs` provider or listen to `fs/*` policy events | | Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | -| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` is the stop boundary | +| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` is the event that stops a turn | | Add model-facing context | call `agent.inject()` to queue sourced context for the next admitted request | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Web Client Chat node | register a `ConversationNodeDefinition` + keyed renderer | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index fec9a00484..53bf9f54a5 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -122,7 +122,7 @@ idle inject: 适配器选择、分发与迭代失败会成为 error 或 aborted 类型的终止 `finish` 分片。`agent/request-error` 接收请求坐标、标准化 `LlmFailure`、可用的重试策略和信号;middleware 与消费方错误仍在恢复之外。失败分片既不提交消息,也不提交工具调用。 -其他故障使用 `agent/error`;取消和 dispose(资源释放)优先于恢复。在提交请求头之前,轮次信号会取消能力准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 会在清空队列和中止前报告原因;空闲调用不发事件。abort 触发后、收敛前到达的唤醒输入会在 driver 的收敛边界执行,而 `disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。持久性以 `aborted` 区分取消,以 `disposed` 区分会等待完全停稳的拆卸([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 +其他故障使用 `agent/error`;取消和 dispose(资源释放)优先于恢复。在提交请求头之前,轮次信号会取消能力准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 会在清空队列和中止前报告原因;空闲调用不发事件。driver 会处理在 abort 开始后、收敛前收到的唤醒输入;`disposed` 取消会让该输入保持待处理状态([取消收敛窗口唤醒锁存](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。持久性以 `aborted` 区分取消,以 `disposed` 区分会等待完全停稳的拆卸([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 轮次和步骤事件均位于轮次边界内;loop 只会在轮次内从进入步骤的批次追加 `user/message`。轮次会在首次领取与 pre-step 之前打开,因此拒绝、空输入、取消或失败会关闭一个不包含任何步骤事件的持久轮次。独立的 `compact/* { turn: null }` 事件不占用轮次,其锁定时刻标记可以与 inbox splice 交错。重新加载会为中断的轮次合成结束事件;`session/end-seed` 区分陈旧的压缩遗留项与活跃锁。关闭后仅由 `agent/error` 报告故障。每个轮次有一个 [TurnEndReason](subsystems/session.md#why-a-turn-ended-turnendreasonmap)。 @@ -142,7 +142,7 @@ idle inject: **模型可见 ⟺ 已记录**:在 `step/start` 进入的消息加上折叠后的 `request/header` 可以重建每个请求。该 header 会标记适配器默认值,使后续提议丢弃这些值并重新解析路由,同时不丢失显式设置。`request/context` 会在路由变化时另行记录与注册项绑定的提供方、模型及容量元数据;它不参与请求重建或 header 相等性判断。`dsh-agent-loop/invariant` 通过 `ctx.invariants` 断言可重建性([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 -持久性由插件负责。后端会将同步的 `session/event` 通知复制到固定窗口的持久化批次中;`session/flush` 会绕过等待,在请求与顶层工具分发之前执行,并在 `turn/end` 之后、另一个轮次或空闲状态之前执行。`SessionPersistence` 存储事件和 header 元数据;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一约定([检查点决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)、[批处理决策](../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md))。 +持久性由插件负责。后端会将同步的 `session/event` 通知复制到固定窗口的持久化批次中;`session/flush` 会绕过等待,在请求与顶层工具分发之前执行,并在 `turn/end` 之后、另一个轮次或空闲状态之前执行。`SessionPersistence` 存储事件和 header 元数据;JSONL 默认采用带校验和的 Zstandard,SQLite 使用同样的检查点与批处理规则([检查点决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)、[批处理决策](../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md))。 在轮次之间,事件所有方通过 `Session` 追加纯日志事件,仅为持久性而刷写。`session/title` 依赖有界后台持久化与生命周期排空;手动压缩会在操作完成前 flush 其标记对。标题工作绝不延迟响应;最新的标题事件生效,并记录来源消息 seq,以及标题由用户、后备逻辑还是提供方提供。标题记录是可继承的 fork 边界([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 @@ -184,8 +184,8 @@ idle inject: | 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派 | | 添加后台工作 | 在 `ctx.tasks` 上注册;通用 `task_*` 工具负责收集或停止 | | 添加文件系统访问或策略 | 实现 `ctx.fs` 提供方,或监听 `fs/*` 策略事件 | -| 限制 spawn 出的进程 | 使用 `ctx.sandbox` 后端;消费方在 spawn 前包装 argv | -| 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 是停止边界 | +| 限制所启动的进程 | 使用 `ctx.sandbox` 后端;消费方在启动进程前包装 argv | +| 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 是停止轮次的事件 | | 添加模型可见上下文 | 调用 `agent.inject()`,将带来源的上下文排入下一次获准请求 | | 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 | | Web Client Chat 节点 | 注册 `ConversationNodeDefinition` + keyed renderer | diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index d8e6851d04..c5cc60a014 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: d5610b6bf6bcb39bfa146ada53ce0734a978b8cd -capability-seams.zh.md: 8a1b38d3600d673a8e0a99941e51d0973101a1c1 +capability-seams.md: 85aee35af0e3de4d7cdbb715bc60c832c023d5ac +capability-seams.zh.md: d3664b649f36b1788c6633497c95d5731ad4a401 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index d5610b6bf6..85aee35af0 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -438,6 +438,6 @@ flowchart LR | `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | | `ctx.httpServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | | `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | -| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | +| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard. diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 8a1b38d360..d3664b649f 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -440,6 +440,6 @@ flowchart LR | `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`、`directory-picker-browse` | `apiproxy` | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | | `ctx.httpServer` | `core` | `webserver` | - | `connection`、`modules`、`hmr` | - | 普通的 node:http 载体:具名路由注册表、索引转换 tap,以及静态 dist 回退;Web 传输插件注册自己的路由。 | | `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | 通过增量 dshClient 扫描组合 __DSH_BOOT__ 入口图,提供插件组合包,并通知重建/图变更订阅方。 | -| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow)、[`tool-ralph`](../packages/workflow/tool-ralph) | - | 每个上下文使用一个引擎(bash 形态,无具名提供方注册表);通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 | +| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow)、[`tool-ralph`](../packages/workflow/tool-ralph) | - | 每个上下文使用一个引擎,与 bash 相同,且没有具名提供方注册表;通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 | 维护模式:混合模式。服务从 Cordis 声明中发现;接口、实现和消费方角色在 `scripts/gen-doc-graphs.ts` 中分类,并设有完整性守卫。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7319ee9070..09c961ef69 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: bf5bdc275e4611afaa6950078459ea34723a0d53 -config-catalog.zh.md: 0d9711d729364d2b06dbc7859f7c0a255222979f +config-catalog.md: 51c6ae46eeca1279390c9d9315a6161edd2de618 +config-catalog.zh.md: dc93f5b4b55b07c52c58405ba4793c2c6eca28df diff --git a/docs/config-catalog.md b/docs/config-catalog.md index bf5bdc275e..51c6ae46ee 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -898,12 +898,12 @@ export type PiAiModelOverride = Omit<PiAiModelProfile, 'id'> * default) or per model (winning over the route). Only the switches pi-ai's * reasoning dispatch reads are offered; the rest of pi-ai's compat surface * keeps its baseURL-derived auto-detection. pi-ai types both fields only on - * `OpenAICompletionsCompat` — the other wire protocols carry their reasoning - * shape in the protocol itself — so resolution rejects a model-level switch + * `OpenAICompletionsCompat` — the other wire protocols define their reasoning + * fields in the protocol itself — so resolution rejects a model-level switch * anywhere else, while a route-level default skips past models it cannot fit. */ export interface PiAiCompatProfile { - /** Reasoning parameter shape the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + /** Reasoning parameter format the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ thinkingFormat?: PiAiThinkingFormat /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ supportsReasoningEffort?: boolean @@ -1161,7 +1161,7 @@ export interface PlanModeConfig { } ``` -Source: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) ## `@deepseek-ai/dsh-pty-local` @@ -1311,7 +1311,7 @@ Source: [`packages/self-modification/repository-plugin/src/index.ts:44`](../pack /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { /** - * Override the runner argv; bwrap-shaped profile arguments are appended. A + * Override the runner argv; bwrap-compatible profile arguments are appended. A * non-empty override asserts full enforcement and skips built-in selection and * probing. A runner that starts but refuses its profile must be identifiable by * {@link runnerFailureSignatures}. Consumers classify a spawn rejection only after @@ -1523,7 +1523,7 @@ Requires: `sessions` ```ts config-catalog /** - * Plugin configuration: one sharing policy, two verbatim SDK option shapes, + * Plugin configuration: one sharing policy, two verbatim SDK option objects, * and one DSH-owned shutdown bound. Uploading modes validate their endpoint * and shutdown deadline at plugin load; `DISABLED` reads neither. */ @@ -1559,7 +1559,7 @@ export enum TelemetryMode { Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/session/session-telemetry-otel/src/index.ts:80`](../packages/session/session-telemetry-otel/src/index.ts) +Source: [`packages/session/session-telemetry-otel/src/index.ts:79`](../packages/session/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` @@ -1807,7 +1807,7 @@ export interface Config { /** * How to auto-answer the child's `session/request_permission` prompts: * `reject` (default — decline every prompt) or `allow` (approve via the first - * allow-shaped option). No prompt is surfaced to a human. + * `allow_once` or `allow_always` option). No prompt is surfaced to a human. */ permission: PermissionPolicy /** @@ -1979,7 +1979,7 @@ export interface Config { persona?: string /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. - * Shape errors fail at load and unknown names fail at assembly; known names + * Invalid fields fail at load and unknown names fail at assembly; known names * hidden in one scope may be absent there. Omitted means lexicographic order. */ toolOrder?: string[] @@ -2448,7 +2448,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:624`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:625`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 0d9711d729..dc93f5b4b5 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -900,12 +900,12 @@ export type PiAiModelOverride = Omit<PiAiModelProfile, 'id'> * default) or per model (winning over the route). Only the switches pi-ai's * reasoning dispatch reads are offered; the rest of pi-ai's compat surface * keeps its baseURL-derived auto-detection. pi-ai types both fields only on - * `OpenAICompletionsCompat` — the other wire protocols carry their reasoning - * shape in the protocol itself — so resolution rejects a model-level switch + * `OpenAICompletionsCompat` — the other wire protocols define their reasoning + * fields in the protocol itself — so resolution rejects a model-level switch * anywhere else, while a route-level default skips past models it cannot fit. */ export interface PiAiCompatProfile { - /** Reasoning parameter shape the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + /** Reasoning parameter format the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ thinkingFormat?: PiAiThinkingFormat /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ supportsReasoningEffort?: boolean @@ -1163,7 +1163,7 @@ export interface PlanModeConfig { } ``` -来源:[`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) ## `@deepseek-ai/dsh-pty-local` @@ -1313,7 +1313,7 @@ export interface Config { /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { /** - * Override the runner argv; bwrap-shaped profile arguments are appended. A + * Override the runner argv; bwrap-compatible profile arguments are appended. A * non-empty override asserts full enforcement and skips built-in selection and * probing. A runner that starts but refuses its profile must be identifiable by * {@link runnerFailureSignatures}. Consumers classify a spawn rejection only after @@ -1525,7 +1525,7 @@ export interface Config { ```ts config-catalog /** - * Plugin configuration: one sharing policy, two verbatim SDK option shapes, + * Plugin configuration: one sharing policy, two verbatim SDK option objects, * and one DSH-owned shutdown bound. Uploading modes validate their endpoint * and shutdown deadline at plugin load; `DISABLED` reads neither. */ @@ -1561,7 +1561,7 @@ export enum TelemetryMode { 依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterNodeConfigBase`(`@opentelemetry/otlp-exporter-base`) -来源:[`packages/session/session-telemetry-otel/src/index.ts:80`](../packages/session/session-telemetry-otel/src/index.ts) +来源:[`packages/session/session-telemetry-otel/src/index.ts:79`](../packages/session/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` @@ -1809,7 +1809,7 @@ export interface Config { /** * How to auto-answer the child's `session/request_permission` prompts: * `reject` (default — decline every prompt) or `allow` (approve via the first - * allow-shaped option). No prompt is surfaced to a human. + * `allow_once` or `allow_always` option). No prompt is surfaced to a human. */ permission: PermissionPolicy /** @@ -1981,7 +1981,7 @@ export interface Config { persona?: string /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. - * Shape errors fail at load and unknown names fail at assembly; known names + * Invalid fields fail at load and unknown names fail at assembly; known names * hidden in one scope may be absent there. Omitted means lexicographic order. */ toolOrder?: string[] @@ -2449,7 +2449,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -来源:[`packages/core/tools/src/index.ts:616`](../packages/core/tools/src/index.ts) +来源:[`packages/core/tools/src/index.ts:617`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index aa582c2bb1..26d61a6041 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md -adding-a-package.md: 79e8604a47a4bdbb7e2912990a8b1c8be300b3b0 -adding-a-package.zh.md: 5fcdf03f52dfdfe375aeb2b6fd41e50ee406d043 +adding-a-package.md: 9e82e6d00177768a6368d1cd9740afa585543171 +adding-a-package.zh.md: 072df33138a1ead20c497cebd8e4aa960c2d1fc8 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 79e8604a47..9e82e6d001 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -53,7 +53,7 @@ Keep package-specific service API, config, events, extension points, and design #### What the model sees -An exact data-dependent shape, an anchored generated-catalog link, or an introduction to the verbatim literal below. +The exact data-dependent fields, an anchored generated-catalog link, or an introduction to the verbatim literal below. ##### Verbatim text for this field, when needed @@ -71,10 +71,10 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex ## Known Limitations and Deferred Work -- **Consumer-visible gap** — exact boundary, consequence, or maintainer constraint. +- **Consumer-visible gap** — exact missing operation or case, its consequence, and any maintainer constraint. ```` -Fill Model Experience from the implementation. Use one H3 per direct, conditional, capped, lifetime, or auxiliary-model surface, with the three ordered H4 fields shown above and one prose paragraph under each. Quote stable text owned by the package: system-prompt prose goes in a titled H5 plus `markdown` fence under the field that introduces it—normally `What the model sees`—other short literals stay inline with named placeholders, and other long literals use the same nested form. Summarize only data-dependent or provider-owned text. A tool-schema surface links its anchored section in the generated [tool catalog](../tool-catalog.md) and states only deltas absent there. Keep prompt and schema surfaces separate when scoping can hide one without the other. In `KV Cache effect`, distinguish append-only growth, a stable repeated prefix, replacement of earlier request tokens, and an independent model request, then name the package-owned changes that can invalidate reuse. “Does not invalidate” means the package preserves an already-reusable prefix; provider cache availability and eviction remain outside the package contract. The [prose standard](../../.agents/skills/dsh-prose-standard/SKILL.md) governs completeness and ownership; the verifier enforces the mechanical shape. +Fill Model Experience from the implementation. Use one H3 per direct, conditional, capped, lifetime, or auxiliary-model surface, with the three ordered H4 fields shown above and one prose paragraph under each. Quote stable text owned by the package: system-prompt prose goes in a titled H5 plus `markdown` fence under the field that introduces it—normally `What the model sees`—other short literals stay inline with named placeholders, and other long literals use the same nested form. Summarize only data-dependent or provider-owned text. A tool-schema surface links its anchored section in the generated [tool catalog](../tool-catalog.md) and states only deltas absent there. Keep prompt and schema surfaces separate when scoping can hide one without the other. In `KV Cache effect`, distinguish append-only growth, a stable repeated prefix, replacement of earlier request tokens, and an independent model request, then name the package-owned changes that can invalidate reuse. “Does not invalidate” means the package preserves an already-reusable prefix; provider cache availability and eviction remain outside the package contract. The [prose standard](../../.agents/skills/dsh-prose-standard/SKILL.md) governs completeness and ownership; the verifier enforces the required section structure. A package with no context effect or one consumer-owned path uses the audited `None, as ` or `Indirectly, through ` sentence in [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts), followed by a `KV Cache effect` H4 and one non-empty paragraph; a model-agnostic generic package may instead join `NO_MODEL_EXPERIENCE_SECTION`. Do not expand either case into a description of another package's work. The limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) is independent. The [Model Experience Agent Note](../../.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md) records the rationale. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index 5fcdf03f52..072df33138 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -53,7 +53,7 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c #### What the model sees -An exact data-dependent shape, an anchored generated-catalog link, or an introduction to the verbatim literal below. +The exact data-dependent fields, an anchored generated-catalog link, or an introduction to the verbatim literal below. ##### Verbatim text for this field, when needed @@ -71,10 +71,10 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex ## Known Limitations and Deferred Work -- **Consumer-visible gap** — exact boundary, consequence, or maintainer constraint. +- **Consumer-visible gap** — exact missing operation or case, its consequence, and any maintainer constraint. ```` -根据实现填写 Model Experience。每个直接、条件、上限、生命周期或辅助模型的 surface 使用一个 H3,包含上述三个有序 H4 字段,每个字段下有一个正文段落。引用包拥有的稳定文本:系统提示词放在引出它的字段下,用带标题的 H5 加 `markdown` 围栏表示,通常归入 `What the model sees`;其他短文本以命名占位符内联,其他长文本使用相同的嵌套形式。仅概述数据依赖或提供方拥有的文本。tool-schema surface 链接到生成的[工具目录](../tool-catalog.md)中对应的锚定章节,仅说明该处缺失的差异。当作用域可以隐藏 prompt 或 schema 其中之一而不影响另一个时,将二者分开。填写 `KV Cache effect` 时,应区分仅追加增长、稳定重复的前缀、替换既有请求 token 和独立模型请求,并列出会使缓存复用失效、且由本包拥有的变化。“不使缓存失效”仅表示本包保留了已有的可复用前缀;缓存是否可用以及何时淘汰不属于本包约定。[行文标准](../../.agents/skills/dsh-prose-standard/SKILL.md)约束完整性与归属;验证器强制执行机械形状。 +根据实现填写 Model Experience。每个直接、条件、上限、生命周期或辅助模型的 surface 使用一个 H3,包含上述三个有序 H4 字段,每个字段下有一个正文段落。引用包拥有的稳定文本:系统提示词放在引出它的字段下,用带标题的 H5 加 `markdown` 围栏表示,通常归入 `What the model sees`;其他短文本以命名占位符内联,其他长文本使用相同的嵌套形式。仅概述数据依赖或提供方拥有的文本。tool-schema surface 链接到生成的[工具目录](../tool-catalog.md)中对应的锚定章节,仅说明该处缺失的差异。当作用域可以隐藏 prompt 或 schema 其中之一而不影响另一个时,将二者分开。填写 `KV Cache effect` 时,应区分仅追加增长、稳定重复的前缀、替换既有请求 token 和独立模型请求,并列出会使缓存复用失效、且由本包拥有的变化。“不使缓存失效”仅表示本包保留了已有的可复用前缀;缓存是否可用以及何时淘汰不属于本包约定。[行文标准](../../.agents/skills/dsh-prose-standard/SKILL.md)约束完整性与归属;验证器强制执行所需章节结构。 没有上下文效果或仅有消费方拥有路径的包使用 [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) 中经过审计的 `None, as ` 或 `Indirectly, through ` 语句,随后添加 `KV Cache effect` H4 和一个非空正文段落;与模型无关的通用包可以改为加入 `NO_MODEL_EXPERIENCE_SECTION`。两种情况都不要展开为对另一个包工作的描述。limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) 独立管理。[Model Experience Agent Note](../../.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md) 记录了设计动机。 diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 16439b1732..e232ad4302 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-tool.md -adding-a-tool.md: cb418a9118901cc6572fb17125351bdda922434b -adding-a-tool.zh.md: 22eccec67a3f941976608dc7fc1cc4120e4e1bd1 +adding-a-tool.md: b030d3c3a6b7dd66b6594779345a96af3a895bd8 +adding-a-tool.zh.md: 4272a7a4571782bc213ca29de1a57d51fbd24075 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index cb418a9118..b030d3c3a6 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -56,7 +56,7 @@ The producer supplies synchronous `cancel`, non-rejecting `done` that settles af ## Execution policy and observation -Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](extension-cookbook.md#a-hook-plugin-permission-gate-example)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap canonical dispatch with a deadline/retry/metrics scope, `tools/post-execute` to replace either presentation content or the canonical value, block, or attach model-facing context, and `tools/result` to observe the immutable normalized outcome. A content replacement leaves programmatic access to `value` intact; confidentiality policy blocks or replaces the value. A sandboxing implementation can also sit behind the tool's executor capability seam; the exact contracts are in the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points). +Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](extension-cookbook.md#a-hook-plugin-permission-gate-example)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap dispatch with a deadline, retry, or metrics collection, `tools/post-execute` to replace presentation content or the returned value, block the result, or attach model-facing context, and `tools/result` to observe the immutable normalized outcome. A content replacement leaves programmatic access to `value` intact; confidentiality policy blocks or replaces the value. A sandboxing implementation can also run inside the tool's executor implementation; the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points) defines each extension point's inputs, order, return values, and failure behavior. ## Code Mode reaches your tool for free @@ -85,7 +85,7 @@ Hard rules (they bite if broken): - **Purity.** These run on live streaming AND on session-log REPLAY, so they must be pure functions of `args` (+ the result) — NO I/O, NO reading session state, NO clock/random. A diff is derived from the args (`write` uses `oldText: null` because a call-time presenter has no prior file content); the UI adapter, not the tool, supplies session context. If you find yourself wanting the file's old content or the working directory inside `presentCall`, stop — that belongs in durable result metadata or the adapter, not the presenter. - **UI-only formatting stays out of the model result.** A fenced ` ```console ` block, a diff, a relativized path—none of these belongs in the canonical value or Native content merely to serve a UI. `output.render` owns model-facing prose; `presentationMeta` plus the card presenters own replayable UI state. A `terminal` result view carries raw output and the adapter adds any fallback framing. -- **`defineTool` soft-validates the display path.** A malformed/older logged arg shape makes the wrapper return `undefined` (a generic fallback) rather than throw — display must never crash a replay. +- **`defineTool` soft-validates the display path.** Malformed or older logged arguments make the wrapper return `undefined` (a generic fallback) rather than throw — display must never crash a replay. The neutral vocabulary lives in `dsh-tools`; tools never import a UI or transport type. Host/client runtimes map each `card` into their own view. The design and the why are in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations. diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 22eccec67a..4272a7a457 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -56,7 +56,7 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 ## 执行策略与观测 -尽量不要把部署策略内建到工具中。使用 `tools/pre-execute` 实现可扩展的允许/拒绝/询问策略(见[权限门禁示例](extension-cookbook.md#a-hook-plugin-permission-gate-example));使用 `ctx.tools.guard()` 设置最终的单调拒绝,后续监听器无法撤销;使用 `tools/execute` 为规范分发包装截止时间/重试/指标作用域;使用 `tools/post-execute` 替换展示内容或规范值、阻止调用,或附加模型可见上下文;使用 `tools/result` 观测不可变的归一化结果而不改变它。替换内容不会阻止程序化访问 `value`;保密策略必须阻止调用或替换值。沙箱实现也可以位于工具执行器的能力 seam 之后;确切约定见 [`dsh-tools` README](../../packages/core/tools/README.md#extension-points)。 +尽量不要把部署策略内建到工具中。使用 `tools/pre-execute` 实现可扩展的允许/拒绝/询问策略(见[权限门禁示例](extension-cookbook.md#a-hook-plugin-permission-gate-example));使用 `ctx.tools.guard()` 设置最终的单调拒绝,后续监听器无法撤销;使用 `tools/execute` 为分发添加截止时间、重试或指标收集;使用 `tools/post-execute` 替换展示内容或返回值、阻止结果,或附加模型可见上下文;使用 `tools/result` 观测不可变的归一化结果而不改变它。替换内容不会阻止程序化访问 `value`;保密策略必须阻止调用或替换值。沙箱实现也可以在工具的执行器实现中运行;[`dsh-tools` README](../../packages/core/tools/README.md#extension-points) 定义每个扩展点的输入、顺序、返回值和失败行为。 ## Code Mode 自动触达你的工具 @@ -85,7 +85,7 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 - **纯函数。** 这些方法在实时流式输出和会话日志回放时都会运行,因此必须是 `args`(加 result)的纯函数——不做 I/O、不读会话状态、不用时钟/随机数。diff 从 args 派生(`write` 使用 `oldText: null`,因为调用时的展示器没有文件先前内容);会话上下文由 UI 适配器而非工具提供。如果你发现自己想在 `presentCall` 内获取文件旧内容或工作目录,请停下:那属于持久结果元数据或适配器,不属于展示器。 - **UI 格式不进入模型结果。** 围栏 ` ```console ` 块、diff、相对化路径均不应仅为服务 UI 而进入规范值或 Native 内容。`output.render` 负责模型可见的自然语言;`presentationMeta` 和卡片展示器负责可回放的 UI 状态。`terminal` 结果视图携带原始输出,由适配器按需添加回退格式。 -- **`defineTool` 对展示路径做软校验。** 格式错误或旧版日志中的 arg 形态会使包装器返回 `undefined`(通用回退)而非抛异常——展示绝不能导致回放崩溃。 +- **`defineTool` 对展示路径做软校验。** 格式错误或旧版日志中的参数会使包装器返回 `undefined`(通用回退)而非抛异常——展示绝不能导致回放崩溃。 中性词汇定义在 `dsh-tools` 中;工具绝不导入 UI 或传输类型。host/client 运行时将每个 `card` 映射到各自的视图。设计与原因见[渲染意图联合体 Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md);`dsh-tool-fs`(generic/diff)和 `dsh-tool-bash`(terminal)是参考实现。 diff --git a/docs/cookbook/adding-a-vendored-package.i18n.yaml b/docs/cookbook/adding-a-vendored-package.i18n.yaml index b8416c384f..4f5b8c3c49 100644 --- a/docs/cookbook/adding-a-vendored-package.i18n.yaml +++ b/docs/cookbook/adding-a-vendored-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-vendored-package.md -adding-a-vendored-package.md: b85d74a3a09b27254883b88cb8e6587e32ed811c -adding-a-vendored-package.zh.md: 2927837a28d1e7b593090d581522f69f84504806 +adding-a-vendored-package.md: 724d89c1c7cd728f7123a6975b5500cd40815851 +adding-a-vendored-package.zh.md: d16ec1056431a4ac1c02d50a5ef0f0a64b67ca6d diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index b85d74a3a0..724d89c1c7 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -9,7 +9,7 @@ When the harness needs another upstream Cordis package (e.g. `@cordisjs/plugin-h ``` vendor/<dir>/ package.json # from upstream; set "private": true, keep name/exports/type - tsconfig.json # extends ../../tsconfig.base.json (see shape below) + tsconfig.json # extends ../../tsconfig.base.json (see configuration below) src/ # the upstream src/ verbatim README.md LICENSE # if upstream ships them ``` @@ -31,7 +31,7 @@ vendor/<dir>/ `package.json` invariants: `"private": true` (vendored packages are never published), keep upstream's `name`/`version`/`exports`/`type`, point declaration metadata at `lib/types`, publish `.d.ts` and `.d.ts.map` declaration outputs, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). -Local relative imports/exports in vendored TypeScript source use explicit `.ts` specifiers after copying. This is a repo-local build-shape divergence from upstream: `rewriteRelativeImportExtensions` emits `.js` runtime imports while declarations keep explicit `.ts` specifiers that NodeNext/Node16 TypeScript consumers can resolve. +Local relative imports/exports in vendored TypeScript source use explicit `.ts` specifiers after copying. This is a repo-local build difference from upstream: `rewriteRelativeImportExtensions` emits `.js` runtime imports while declarations keep explicit `.ts` specifiers that NodeNext/Node16 TypeScript consumers can resolve. ## 2. Register it in the root configs @@ -42,7 +42,7 @@ Local relative imports/exports in vendored TypeScript source use explicit `.ts` | `vendor/README.md` | add a manifest table row (dir, npm name, version, upstream repo, commit SHA) and log any local modifications | | `scripts/publint-all.ts` | only if the vendored package is itself published from here (vendored deps normally are not — skip) | -Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `.oxlintrc.json`. A per-package `vendor/<dir>/tsdown.config.ts` is needed ONLY if the build shape diverges from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`); its entry should read the JS emitted under `lib/types`. +Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `.oxlintrc.json`. A per-package `vendor/<dir>/tsdown.config.ts` is needed ONLY if the build configuration differs from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`); its entry should read the JS emitted under `lib/types`. ## 3. Mind the manifest guard diff --git a/docs/cookbook/adding-a-vendored-package.zh.md b/docs/cookbook/adding-a-vendored-package.zh.md index 2927837a28..d16ec10564 100644 --- a/docs/cookbook/adding-a-vendored-package.zh.md +++ b/docs/cookbook/adding-a-vendored-package.zh.md @@ -9,7 +9,7 @@ ``` vendor/<dir>/ package.json # from upstream; set "private": true, keep name/exports/type - tsconfig.json # extends ../../tsconfig.base.json (see shape below) + tsconfig.json # extends ../../tsconfig.base.json (see configuration below) src/ # the upstream src/ verbatim README.md LICENSE # if upstream ships them ``` @@ -31,7 +31,7 @@ vendor/<dir>/ `package.json` 的不变式:`"private": true`(vendored 包永不发布);保留上游的 `name`/`version`/`exports`/`type`;声明元数据指向 `lib/types`;发布 `.d.ts` 与 `.d.ts.map` 声明输出;在 `peerDependencies` 中列出其 Cordis 依赖(与上游 manifest(元数据清单)一致)。传递性上游依赖本身也必须被 vendor 或已存在于仓库中——vendor 一个包往往意味着 vendor 其整条依赖树(如 `@cordisjs/plugin-http` 会拉入 `@cordisjs/fetch-file`)。 -vendored TypeScript 源码中的本地相对导入/导出在复制后使用显式 `.ts` 后缀。这是仓库本地的构建形态与上游的差异:`rewriteRelativeImportExtensions` 输出 `.js` 运行时导入,而声明文件保留显式 `.ts` 后缀,使 NodeNext/Node16 的 TypeScript 消费方能够解析。 +vendored TypeScript 源码中的本地相对导入/导出在复制后使用显式 `.ts` 后缀。这是仓库本地构建与上游的差异:`rewriteRelativeImportExtensions` 输出 `.js` 运行时导入,而声明文件保留显式 `.ts` 后缀,使 NodeNext/Node16 的 TypeScript 消费方能够解析。 ## 2. 在根配置中注册 @@ -42,7 +42,7 @@ vendored TypeScript 源码中的本地相对导入/导出在复制后使用显 | `vendor/README.md` | 添加一行 manifest 表格行(dir、npm name、version、upstream repo、commit SHA)并记录所有本地修改 | | `scripts/publint-all.ts` | 仅当该 vendored 包本身从此仓库发布时才需要(vendored 依赖通常不发布——跳过) | -以下由 glob 自动覆盖,无需手动编辑:根 `package.json` 的 workspaces(`vendor/*`)、`tsdown.config.ts`、`vitest.config.ts`、`.oxlintrc.json`。只有当构建形态偏离根默认值时(双 ESM/CJS 或多入口——参见 `vendor/schemastery` 和 `vendor/logger-console`),才需要单独的 `vendor/<dir>/tsdown.config.ts`;其入口应读取 `lib/types` 下输出的 JS。 +以下由 glob 自动覆盖,无需手动编辑:根 `package.json` 的 workspaces(`vendor/*`)、`tsdown.config.ts`、`vitest.config.ts`、`.oxlintrc.json`。只有当构建配置与根默认值不同时(双 ESM/CJS 或多入口——参见 `vendor/schemastery` 和 `vendor/logger-console`),才需要单独的 `vendor/<dir>/tsdown.config.ts`;其入口应读取 `lib/types` 下输出的 JS。 ## 3. 注意 manifest 守卫 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 6e83450ec3..ac600e8c6d 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: aba220ec6dc3cf3d0f99edd0e47ffa6069499b47 -extension-cookbook.zh.md: 34e8fc0fa3d2d57136616d66cfb4f3f6de20605e +extension-cookbook.md: 95ba269a5d62e14cfde487d5a3aaca5db493657e +extension-cookbook.zh.md: e3fbe09f1ec09568e3b259aee361d33ba3e62140 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index aba220ec6d..95ba269a5d 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -2,11 +2,11 @@ English | [中文](extension-cookbook.zh.md) -Reference shapes for the harness extension surface. The snippets omit imports and helper implementations and are not copy-paste-complete. For concrete authoring paths, see the [package checklist](adding-a-package.md), [first-tool tutorial](../user/develop/basic/tool.md), [tool reference](adding-a-tool.md), and [LLM adapter guide](adding-an-llm-adapter.md); the [architecture](../architecture.md) owns the system and extension-point map. +Reference patterns for harness extensions. The snippets omit imports and helper implementations and are not copy-paste-complete. For concrete authoring paths, see the [package checklist](adding-a-package.md), [first-tool tutorial](../user/develop/basic/tool.md), [tool reference](adding-a-tool.md), and [LLM adapter guide](adding-an-llm-adapter.md); the [architecture](../architecture.md) owns the system and extension-point map. ## A tool plugin -A tool registers on `ctx.tools`. The annotated `defineTool` example (typed `execute` args, result shaping, the `run_in_background` pattern) lives in [adding-a-tool.md](adding-a-tool.md) — that guide is the source of truth for the tool shape. Raw JSON-Schema `ToolDefinition`s are also accepted by `ctx.tools.register()` directly (that is how MCP-sourced tools arrive); `defineTool` is the typed sugar for first-party tools. +A tool registers on `ctx.tools`. The annotated `defineTool` example (typed `execute` arguments, result construction, the `run_in_background` pattern) lives in [adding-a-tool.md](adding-a-tool.md) — that guide is the source of truth for tool definitions. Raw JSON-Schema `ToolDefinition`s are also accepted by `ctx.tools.register()` directly (that is how MCP-sourced tools arrive); `defineTool` is the typed helper for first-party tools. ## A hook plugin (permission-gate example) @@ -64,7 +64,7 @@ export function apply(ctx: Context) { A *protocol driver* adapts a wire peer to `ctx.agents`; it may serve a UI or an automation client. A stdio driver owns stdout, creates or resumes agents through the factory, and maps protocol requests to `followup()` or `cancel()`. A low-level prompt request returns its durable enqueue receipt; it does not acquire a result by correlating `MessageId` with `turn/end`. Publish whole-agent status separately. An automation method may wait from its receipt through the next idle and summarize that explicitly owned interval, while a UI normally keeps observing the open-ended event stream. Tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. -[`packages/acp/acp`](../../packages/acp/acp) is the automation-only worked example: it exposes fresh text sessions over Agent Client Protocol JSON-RPC stdio, emits committed assistant text, and registers a one-shot machine permission answerer for agents it owns. Its [README](../../packages/acp/acp/README.md) owns the exact method and lifecycle contract. +[`packages/acp/acp`](../../packages/acp/acp) is the automation-only worked example: it exposes fresh text sessions over Agent Client Protocol JSON-RPC stdio, emits committed assistant text, and registers a one-shot machine permission answerer for agents it owns. Its [README](../../packages/acp/acp/README.md) defines the exact methods, event order, and lifecycle contract. ```ts import type { Context } from 'cordis' diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 34e8fc0fa3..e3fbe09f1e 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -2,11 +2,11 @@ [English](extension-cookbook.md) | 中文 -harness 扩展表面的参考形态。代码片段省略了 import 和辅助实现,无法直接复制运行。具体编写路径见[包检查清单](adding-a-package.md)、[第一个工具教程](../user/develop/basic/tool.md)、[工具参考](adding-a-tool.md)和 [LLM(大语言模型)适配器指南](adding-an-llm-adapter.md);系统与扩展点映射由[架构文档](../architecture.md)负责。 +harness 扩展的参考模式。代码片段省略了 import 和辅助实现,无法直接复制运行。具体编写路径见[包检查清单](adding-a-package.md)、[第一个工具教程](../user/develop/basic/tool.md)、[工具参考](adding-a-tool.md)和 [LLM(大语言模型)适配器指南](adding-an-llm-adapter.md);系统与扩展点映射由[架构文档](../architecture.md)负责。 ## 工具插件 -工具在 `ctx.tools` 上注册。带注解的 `defineTool` 示例(类型化的 `execute` 参数、结果塑形、`run_in_background` 模式)见 [adding-a-tool.md](adding-a-tool.md)——该指南是工具形态的真源。`ctx.tools.register()` 也直接接受原始 JSON Schema `ToolDefinition`(MCP 来源的工具就是这样到达的);`defineTool` 是为第一方工具提供的类型化语法糖。 +工具在 `ctx.tools` 上注册。带注解的 `defineTool` 示例(类型化的 `execute` 参数、结果构造、`run_in_background` 模式)见 [adding-a-tool.md](adding-a-tool.md)——该指南是工具定义的真源。`ctx.tools.register()` 也直接接受原始 JSON Schema `ToolDefinition`(MCP 来源的工具就是这样到达的);`defineTool` 是第一方工具使用的类型化辅助函数。 ## 钩子插件(以权限门禁为例) @@ -64,7 +64,7 @@ export function apply(ctx: Context) { *协议驱动*将协议对端接入 `ctx.agents`;它可以服务于 UI 或自动化客户端。stdio 驱动拥有 stdout,通过工厂创建或恢复 agent(智能体),并将协议请求映射为 `followup()` 或 `cancel()`。底层提示词请求返回其持久入队回执;它不会通过关联 `MessageId` 与 `turn/end` 获得结果。整个 agent 的状态应单独发布。自动化方法可以从回执等待到下一次 idle,并概括这一显式拥有的区间;UI 通常则会持续观察开放式事件流。通过 `AgentHandle.dispose()` 拆除 agent,以使 dispose(资源释放)达到完全停稳。 -[`packages/acp/acp`](../../packages/acp/acp) 是仅面向自动化的完整示例:它通过 ACP(Agent Client Protocol)JSON-RPC stdio 提供全新文本会话,发出已提交的助手文本,并为其拥有的 agent 注册一次性机器权限应答器。其 [README](../../packages/acp/acp/README.md) 拥有精确的方法和生命周期约定。 +[`packages/acp/acp`](../../packages/acp/acp) 是仅面向自动化的完整示例:它通过 ACP(Agent Client Protocol)JSON-RPC stdio 提供全新文本会话,发出已提交的助手文本,并为其拥有的 agent 注册一次性机器权限应答器。其 [README](../../packages/acp/acp/README.md) 定义确切的方法、事件顺序和生命周期约定。 ```ts import type { Context } from 'cordis' diff --git a/docs/cookbook/maintaining-dsh-code-review.i18n.yaml b/docs/cookbook/maintaining-dsh-code-review.i18n.yaml index 331373fb1e..8ceb56b26b 100644 --- a/docs/cookbook/maintaining-dsh-code-review.i18n.yaml +++ b/docs/cookbook/maintaining-dsh-code-review.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/maintaining-dsh-code-review.md -maintaining-dsh-code-review.md: c8517054434f4b090c67455cda0a992c2c3173ee -maintaining-dsh-code-review.zh.md: 3d26d540a41661aa45b41d6738118f36d904ccf3 +maintaining-dsh-code-review.md: a8c2a66c065aaec5c03f0ab6965377d1d1eb14bd +maintaining-dsh-code-review.zh.md: c72323c4ab0c31fa30aa3ef0e8ea41b129ad0c1d diff --git a/docs/cookbook/maintaining-dsh-code-review.md b/docs/cookbook/maintaining-dsh-code-review.md index c851705443..a8c2a66c06 100644 --- a/docs/cookbook/maintaining-dsh-code-review.md +++ b/docs/cookbook/maintaining-dsh-code-review.md @@ -20,7 +20,7 @@ Each run stores its artifacts on the operator's machine. The saved diff, candida When a run produces a candidate, a macOS notification arrives with a `dsh-code-review-promote <timestamp>` hint. -1. **Read the diff on its own merits.** Do not defer to "the reviewers approved" — the maintainer contract is that the operator is the final judgment. Look for checklist bloat, historical prose, unsupported extrapolation from a single incident, and duplicated coverage with existing skill or authoritative-doc content. +1. **Read the diff on its own merits.** Do not defer to "the reviewers approved"; the maintainer contract is that the operator makes the final decision. Look for checklist bloat, historical prose, unsupported extrapolation from a single incident, and duplicated coverage with existing skill or authoritative-doc content. ```sh ls ~/dsh-code-review-outputs/ # every candidate ever produced diff --git a/docs/cookbook/maintaining-dsh-code-review.zh.md b/docs/cookbook/maintaining-dsh-code-review.zh.md index 3d26d540a4..c72323c4ab 100644 --- a/docs/cookbook/maintaining-dsh-code-review.zh.md +++ b/docs/cookbook/maintaining-dsh-code-review.zh.md @@ -20,7 +20,7 @@ 某次运行产出候选版本时,macOS 会发出一条带 `dsh-code-review-promote <timestamp>` 提示的通知。 -1. **根据 diff 本身作出判断。** 不要因为「评审者已经批准」就直接接受:维护者约定规定最终判断由操作员作出。检查清单是否膨胀、是否有历史叙述、是否根据单次事件作出无依据的外推,以及是否与现有 skill 或权威文档重复。 +1. **根据 diff 本身作出判断。** 不要因为「评审者已经批准」就直接接受;维护者约定规定由操作员作出最终决定。检查清单是否膨胀、是否有历史叙述、是否根据单次事件作出无依据的外推,以及是否与现有 skill 或权威文档重复。 ```sh ls ~/dsh-code-review-outputs/ # every candidate ever produced diff --git a/docs/cordis-primer.i18n.yaml b/docs/cordis-primer.i18n.yaml index 9e1143231e..12177e3d35 100644 --- a/docs/cordis-primer.i18n.yaml +++ b/docs/cordis-primer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-primer.md -cordis-primer.md: 4bcb2c9979994ca70f92031cbdc5dd22df9c1977 +cordis-primer.md: c95909a4a1deab9407efedbb990ef13be6e43a16 cordis-primer.zh.md: a18b8b37af19a610b71babbe5e67f96bb09e81b1 diff --git a/docs/cordis-primer.md b/docs/cordis-primer.md index 4bcb2c9979..c95909a4a1 100644 --- a/docs/cordis-primer.md +++ b/docs/cordis-primer.md @@ -23,7 +23,7 @@ Every event can have one of the following dispatch mode and can only be dispatch | `parallel` | Yes | all listeners observe the event in parallel | No | | `serial` | Yes | listeners observe in registration order | Yes | -The mode is part of the event's public contract. New harness events document it with an `@mode` tag so the generated catalog can check declarations against dispatch sites. +The dispatch mode is part of the event's public contract. New harness events document it with an `@mode` tag so the generated catalog can check declarations against dispatch sites. ## Cordis Waterfall Semantics diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml index 1fe3585ba1..9bf649ab29 100644 --- a/docs/cordis-tutorial/01-first-plugin.i18n.yaml +++ b/docs/cordis-tutorial/01-first-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/01-first-plugin.md -01-first-plugin.md: 4359dfe4883f12e9cb242cf3009827fd7864768c -01-first-plugin.zh.md: 9965f4ddb75fa338ba7fd9d564bd4a32fced7b93 +01-first-plugin.md: 260026329443f9a5b8860d11a6527dbd687eb44c +01-first-plugin.zh.md: 69dedb898c7ea29f99233f07126cd413fa0ddbe2 diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md index 4359dfe488..2600263294 100644 --- a/docs/cordis-tutorial/01-first-plugin.md +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -52,7 +52,7 @@ There is no framework bootstrap code in your file: a plugin describes what it co ## The two other plugin shapes -A function is the most common shape, but Cordis accepts three: +A function is the most common form, but Cordis accepts three: ```ts import { Service, type Context } from 'cordis' diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md index 9965f4ddb7..69dedb898c 100644 --- a/docs/cordis-tutorial/01-first-plugin.zh.md +++ b/docs/cordis-tutorial/01-first-plugin.zh.md @@ -52,7 +52,7 @@ hello from my first plugin ## 其他两种插件形态 -函数是最常见的形态,但 Cordis 接受三种形态: +函数是最常见的形式,但 Cordis 接受三种形式: ```ts import { Service, type Context } from 'cordis' diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml index fa810d635f..719e949ffe 100644 --- a/docs/cordis-tutorial/index.i18n.yaml +++ b/docs/cordis-tutorial/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/index.md -index.md: 7a0bb6f8c736bf31d655a7763cfb7039c343d1a2 -index.zh.md: e6f6dc0cccef3f44273655b98b695bdc4632e95a +index.md: 307c12854b3075cfd4dd5ea8a19806c58b4e998d +index.zh.md: a0107b7d15272e6ef8d526b9c0e03a99275644d6 diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md index 7a0bb6f8c7..307c12854b 100644 --- a/docs/cordis-tutorial/index.md +++ b/docs/cordis-tutorial/index.md @@ -53,6 +53,6 @@ The examples use three TypeScript features beyond ordinary modern JavaScript: - **`import type { Context } from 'cordis'`** imports only type information. It vanishes at runtime, so a plugin file that needs `Context` solely for annotations adds no runtime dependency. - **Declaration merging** (`declare module 'cordis' { ... }`) adds your entries to interfaces that Cordis already declares — for example the type of a new `ctx.greeter` property or event name. It generates no runtime wiring; the plugin separately provides the service or emits the event. Chapter 3 shows the pattern in full. -Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema<Config>` to say which object shape a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. +Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema<Config>` to say which object fields a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md index e6f6dc0ccc..a0107b7d15 100644 --- a/docs/cordis-tutorial/index.zh.md +++ b/docs/cordis-tutorial/index.zh.md @@ -53,6 +53,6 @@ node --import tsx ../../vendor/cordis/bin.js - **`import type { Context } from 'cordis'`** 只导入类型信息。它在运行时会消失,因此仅为类型注解使用 `Context` 的插件文件不会增加运行时依赖。 - **声明合并**(`declare module 'cordis' { ... }`)会为 Cordis 已经声明的接口添加你的条目,例如新 `ctx.greeter` 属性的类型或事件名称。它不会生成任何运行时接线;插件必须另行提供服务或发出事件。第 3 章会完整展示该模式。 -第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema<Config>` 这类泛型表示 schema 所校验的对象形状。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 +第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema<Config>` 这类泛型表示 schema 校验哪些对象字段。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index 68f62583c2..18b28ca58c 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/defensive-patterns.md -defensive-patterns.md: afb462e120892eafe676d8b7feef273c2d0e42df -defensive-patterns.zh.md: ab5f689d47f89b6930b749824c69325490bd4586 +defensive-patterns.md: 368c9876f1a4e7042b003f6acfb30af3b2daf402 +defensive-patterns.zh.md: c7d4c1bf37ef17947913ac4011624d04ffd8c1a3 diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index afb462e120..368c9876f1 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -10,7 +10,7 @@ A result can be several things at once — a process can time out AND exit 0 bec ## Honor public contracts on BOTH sides -When an implementation boundary receives several representations of one outcome, normalize them before crossing the public contract. `LlmAdapter.stream()` implementations may throw or emit `finish {kind:'error'|'aborted'}`, but `LlmService.stream()` exposes model-request failures only as terminal finish chunks; middleware and consumer defects remain thrown. This keeps consumers from guessing whether a caught exception came from the provider, a wrapper, chunk logging, or their own assembly. Document the normalized contract where the type is defined; exercise every source form through the real consumer. +When an implementation receives several representations of one outcome, normalize them before returning through the public API. `LlmAdapter.stream()` implementations may throw or emit `finish {kind:'error'|'aborted'}`, but `LlmService.stream()` exposes model-request failures only as terminal finish chunks; middleware and consumer defects remain thrown. This keeps consumers from guessing whether a caught exception came from the provider, a wrapper, chunk logging, or their own assembly. Document the normalized contract where the type is defined; exercise every source form through the real consumer. ## Async state is not synchronous state @@ -20,7 +20,7 @@ When an implementation boundary receives several representations of one outcome, A teardown that issues kills/aborts but returns before the work stops leaves orphans. Make cleanup async and await the children's exit (kill → await `done`), and close listener/notification registries BEFORE killing so late completions stay silent. -## Contain callback exceptions at the boundary +## Contain callback exceptions in the dispatcher A user-supplied listener that throws must not reject the promise it runs inside or starve the listeners after it. Wrap the dispatch loop in try/catch and log; one bad subscriber never breaks core lifecycle. diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index ab5f689d47..c7d4c1bf37 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -10,7 +10,7 @@ ## 公共约定两侧都要遵守 -当一个实现边界接收到同一结果的多种表示时,应在跨越公共约定前将其规范化。`LlmAdapter.stream()` 的实现可以抛出异常或发出 `finish {kind:'error'|'aborted'}`,但 `LlmService.stream()` 只会通过终止 finish chunk 暴露模型请求失败;middleware 与消费方缺陷仍会抛出。这使消费方不必猜测捕获的异常究竟来自提供方、包装层、chunk 日志记录还是自身组装逻辑。请在类型定义处记录规范化约定;通过真实消费方覆盖每种来源形式。 +当一个实现收到同一结果的多种表示时,应在通过公共 API 返回前将其规范化。`LlmAdapter.stream()` 的实现可以抛出异常或发出 `finish {kind:'error'|'aborted'}`,但 `LlmService.stream()` 只会通过终止型 finish 分片暴露模型请求失败;middleware 缺陷与消费方缺陷仍会以异常形式抛出。这使消费方不必猜测捕获的异常究竟来自提供方、包装层、chunk 日志记录还是自身组装逻辑。请在类型定义处记录规范化后的约定;通过真实消费方覆盖每种来源形式。 ## 异步状态不是同步状态 @@ -20,7 +20,7 @@ 如果清理流程只发出终止或中止信号便返回,而不等待工作真正停止,就会留下孤儿进程。清理逻辑应采用异步流程,并等待子进程退出(发出终止信号后等待 `done`);还应在终止进程前关闭监听器和通知注册表,使迟到的完成事件保持静默。 -## 在边界处隔离回调异常 +## 在分发器中隔离回调异常 用户提供的监听器如果抛出异常,不得导致它所在的 promise 被 reject,也不得饿死排在它后面的监听器。请用 try/catch 包裹分发循环并记录日志;一个行为不当的订阅者绝不能破坏核心生命周期。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 84cff8fa96..da29debe08 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 8b6c148d87fdd6288695b0029b9dfcc0139d2144 -development.zh.md: 55ca013cc0dd901500e340a7447057de27665f61 +development.md: 8e565f21c6e2ede7dab7dbda3c4b18b77ce0920f +development.zh.md: d9c0fbfbb663334b8f7e2ca11d4e8d9a0652c22e diff --git a/docs/development.md b/docs/development.md index 8b6c148d87..8e565f21c6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,7 +2,7 @@ English | [中文](development.zh.md) -The setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts. +The setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts. ## Setup tutorial @@ -51,7 +51,7 @@ The repository uses isolated Host and Client aggregates. An ordinary package is | `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes | | `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes | | `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No | -| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No | +| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No | Host and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow: @@ -59,7 +59,7 @@ Host and Client stay two aggregate programs because both sides declaration-merge - A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. - A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase. -`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary. +`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order. The root build follows the generated dependency order: @@ -75,7 +75,7 @@ Both tsdown passes use the same complete workspace match. They neither scan buil TypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision. -Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already owns an explicit dependency on the TypeRT contract pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. +Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the TypeRT contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. Business services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. @@ -100,7 +100,7 @@ DEEPSEEK_BASE_URL=https://... # optional ### Git integrations -The pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact boundary. +The pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts. The installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states. @@ -156,10 +156,10 @@ Pick the tag that matches the urgency so anyone scanning the code can tell a rel ### Documenting types verbatim (`ts type-equiv`) -The [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: +The [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: ```json { "doc": "docs/subsystems/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change. +`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change. diff --git a/docs/development.zh.md b/docs/development.zh.md index 55ca013cc0..d9c0fbfbb6 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -2,7 +2,7 @@ [English](development.md) | 中文 -搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。 +搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。 ## 搭建教程 @@ -51,7 +51,7 @@ pnpm run typecheck | `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 | | `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 | | `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 | -| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 | +| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 | Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律: @@ -59,7 +59,7 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 - 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。 - 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。 -`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。 +`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。 根构建按生成依赖排序: @@ -75,7 +75,7 @@ pnpm run build:web TypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。 -静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本以调用它的公共命令或调度器门禁已经显式依赖 TypeRT 约定 pass 或完整构建为前提。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。 +静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 TypeRT 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。 业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 @@ -100,7 +100,7 @@ DEEPSEEK_BASE_URL=https://... # optional ### Git 集成 -当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。确切边界见[双语文档约定](i18n/README.md#the-pairing-contract)。 +当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。 安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。 @@ -156,10 +156,10 @@ pnpm run demo:acp ### 逐字记录类型(`ts type-equiv`) -[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: +[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: ```json { "doc": "docs/subsystems/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。 +`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 3bac30f742..a2caf7b784 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: 4b7a8794808cc02daac7031f4981a765c97ca81a -event-producer-consumer.zh.md: 976f41d7798e9182b60366d77e546d79c4a66a13 +event-producer-consumer.md: b78171ce51931f02a3f39ef98104ea9dedc27360 +event-producer-consumer.zh.md: c044385bf91559f5c4f82d99601642b932066e7f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4b7a879480..b78171ce51 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -44,12 +44,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:192`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:174`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 976f41d779..c044385bf9 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -46,12 +46,12 @@ | `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`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:192`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:174`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/graph-atlas.i18n.yaml b/docs/graph-atlas.i18n.yaml index 0cd73d4f39..09b1b58178 100644 --- a/docs/graph-atlas.i18n.yaml +++ b/docs/graph-atlas.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/graph-atlas.md -graph-atlas.md: 5b831520fb4e1c5d49d83738ba979ce04ce4f69f -graph-atlas.zh.md: 1f9a30124284549248bbb21f926772d5cdc2598a +graph-atlas.md: bf2aeba1210709cdda68e0ea7d611528f3191744 +graph-atlas.zh.md: 780e5295f74f10ee4fe762e280e5c1c820c481c4 diff --git a/docs/graph-atlas.md b/docs/graph-atlas.md index 5b831520fb..bf2aeba121 100644 --- a/docs/graph-atlas.md +++ b/docs/graph-atlas.md @@ -3,7 +3,7 @@ # Documentation Graph Index -These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the [subsystem pages](subsystems/core.md) (types + the generated `cordis-surface` regions) and [tool-catalog.md](tool-catalog.md). +These diagrams show relationships that the generated catalogs do not. Use them to find package relationships, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type definitions still live in the [subsystem pages](subsystems/core.md) (types + the generated `cordis-surface` regions) and [tool-catalog.md](tool-catalog.md). The process decision behind this index is recorded in [the documentation graph Agent Note](../.agents/notes/archived/process/2026-07-03-documentation-graph-atlas.md). diff --git a/docs/graph-atlas.zh.md b/docs/graph-atlas.zh.md index 1f9a301242..780e5295f7 100644 --- a/docs/graph-atlas.zh.md +++ b/docs/graph-atlas.zh.md @@ -5,7 +5,7 @@ [English](graph-atlas.md) | 中文 -这些图构成生成目录之上的关系层。你可以借助它们了解包拓扑、能力 seam、事件流、面向模型的工具、应用组合以及运行时生命周期路径。精确签名和类型结构仍以[子系统页面](subsystems/core.md)(类型和生成的 `cordis-surface` 区域)及[工具目录](tool-catalog.md)为准。 +这些图展示生成目录未包含的关系。可以用它们查找包之间的关系、能力 seam、事件流、面向模型的工具、应用组合和运行时生命周期路径。精确签名和类型定义仍以[子系统页面](subsystems/core.md)(类型和生成的 `cordis-surface` 区域)及[工具目录](tool-catalog.md)为准。 本索引背后的流程决策记录在[文档图 Agent Note](../.agents/notes/archived/process/2026-07-03-documentation-graph-atlas.md)中。 diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index d84f04b02a..45e4077203 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/i18n/README.md -README.md: 042fe71e796340c5c653e1f671c53a95a8496a38 -README.zh.md: d2d3b84e98cf4b761ae0174459b91236a71fb187 +README.md: af6a35294bc23adcd6214747f78a28a69ae443d1 +README.zh.md: 74cb98932460d014dab26d3b48cd81142e0f7bf8 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 042fe71e79..af6a35294b 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, enforcement gate, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). +This repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). ## The pairing contract @@ -17,7 +17,7 @@ This repo's documentation is read by people and agents both inside and outside t Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief <pair>` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form). - When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any uncertain shape remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives. + When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives. - **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output. - **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`). @@ -37,7 +37,7 @@ Source-oriented code gates consume an exact `.zh.md` fence sequence as a derivat The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write <pair>`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI. -The gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. +The gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. ## Scope and exclusions diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index d2d3b84e98..74cb989324 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、强制门禁、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。 +本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。 ## 配对约定 @@ -17,7 +17,7 @@ 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief <pair>` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。 - 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何无法确定的情形都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。 + 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。 - **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。 - **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 @@ -37,7 +37,7 @@ 这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write <pair>`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。 -把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。 +门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。 ## 范围与排除 diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index 1b8a0e3f1a..eb8a4b673b 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -14,9 +14,9 @@ 依赖约束规范:各类扩展插件仅依赖抽象接口,严禁直接依赖 `dsh-agent-loop`(该主循环支持替换实现);唯一允许的特例是组合包 `dsh-agent-spine-demo`,它的职责是组装整套实体主干。 -> This document covers **behavior**; type shapes live in [subsystems/](../subsystems/core.md), the per-event/service reference in the generated regions of [subsystems/](../subsystems/core.md), per-package contracts in the package READMEs ([map](../../packages/README.md)). +> This document covers **behavior**; type definitions live in [subsystems/](../subsystems/core.md), the per-event/service reference lives in the generated regions of [subsystems/](../subsystems/core.md), and package contracts in the package READMEs state each package's required configuration and behavior ([map](../../packages/README.md)). -本文档描述整体行为逻辑;类型定义存放于 [subsystems/](../subsystems/core.md);各类事件、服务的详细参考见 [subsystems/](../subsystems/core.md) 中的生成区块;各包(package)的对外约定写在相应的 README 中([索引](../../packages/README.md))。 +本文档描述整体行为逻辑;类型定义存放于 [subsystems/](../subsystems/core.md);各类事件、服务的详细参考见 [subsystems/](../subsystems/core.md) 中的生成区块;相应的 README 说明每个包(package)要求的配置和行为([索引](../../packages/README.md))。 ## ② 防御模式规则 @@ -46,9 +46,9 @@ 自带自动跳过逻辑,仅用于保障无密钥 CI 环境、无权限贡献者不会被流程拦截,不代表可以以此为由削减真实接口测试投入。 -> **Prefer the real implementation over a mock** — Mock only the genuinely expensive or non-deterministic boundary (the LLM adapter, the network, the clock); keep everything downstream real. A hand-rolled stand-in proves the bridge moves bytes, not that the shipping tool behaves as asserted — the two drift while the test stays green. +> **Prefer the real implementation over a mock** — Mock only genuinely expensive or non-deterministic dependencies (the LLM adapter, the network, the clock); keep everything downstream real. A hand-rolled stand-in proves the bridge moves bytes, not that the shipping tool behaves as asserted — the two drift while the test stays green. -**优先使用真实实现,而非 mock 替身**——仅对开销极大、结果不确定的边界模块做 mock(LLM(大语言模型)适配器、网络、时钟),其余下游组件全部使用真实实现。手写的 mock 替身只能验证数据通路能传输字节,无法保证线上工具符合预期逻辑;长期下来业务逻辑与 mock 实现会出现偏差,但测试仍会显示通过。 +**优先使用真实实现,而非 mock 替身**——仅对开销极大、结果不确定的依赖做 mock(LLM(大语言模型)适配器、网络、时钟),其余下游组件全部使用真实实现。手写的 mock 替身只能验证数据通路能传输字节,无法保证线上工具符合预期逻辑;长期下来业务逻辑与 mock 实现会出现偏差,但测试仍会显示通过。 ## ④ 机制描述 @@ -58,9 +58,9 @@ ## ⑤ 政策声明 -> The gate's limit, stated plainly: a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound. It checks hashes and shape; it cannot judge whether the two sides actually say the same thing — that is the reviewer's half of the contract. A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. +> The gate's limit, stated plainly: a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound. It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing — that is the reviewer's half of the contract. A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. -门禁的边界很明确:通过门禁只说明两侧文件当前的 blob hash 与伴随记录吻合,并且结构签名一致,也就是说,这组内容曾被确认一致;它不代表这次确认可靠。门禁无法判断两种语言是否真正表达了相同的意思;这部分约定要由评审人把关。即使译文粗糙、表意有误,重新记录配对后仍能通过门禁,但绝不能通过人工评审。 +门禁的限制很明确:通过门禁只说明两侧文件当前的 blob hash 与伴随记录吻合,并且 Markdown 结构签名一致,也就是说,这组内容曾被确认一致;它不代表这次确认可靠。评审人必须检查两种语言是否真正表达了相同的意思。即使译文粗糙、表意有误,重新记录配对后仍能通过门禁,但绝不能通过人工评审。 ## ⑥ Agent Note 论证 @@ -84,4 +84,4 @@ - 长段按语义单元拆段,一段一件事;名词短语展开为动词句。 - 母语重写不等于删减:原文每个语义成分都要落地。 - 样例与 [terminology.md](terminology.md) 冲突时,以术语表为准:收录样例前按表修正术语(例如 agent、mock、LLM 保留英文,cancellation 译「取消」)。 -- 代码体标识符(事件名 `agent/status`、状态值 `running`、包名 `dsh-bash-local` 等)在译文中保留 code span 原文,不得口语化改写——这是行文规则的硬边界,Pass 2 逐句核验的重点。 +- 代码体标识符(事件名 `agent/status`、状态值 `running`、包名 `dsh-bash-local` 等)在译文中保留 code span 原文,不得口语化改写;Pass 2 必须逐句核验。 diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index d8c6d95f73..b4ade80981 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -70,7 +70,7 @@ A lower-priority rule may refine but never override a higher-priority requiremen - The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it. - Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions. - Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction. -- Prefer established target-language engineering idiom over literal renderings, and localize metaphors instead of transplanting them. +- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning. - Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`. - Keep the author's register: concise stays concise, detailed stays detailed. @@ -127,7 +127,7 @@ A terminology table is provided below. Follow it strictly: ## Output Format -Return exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required shape; do not reproduce the fence. +Return exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence. The outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping. @@ -152,10 +152,10 @@ The outer section tags are framing. If Markdown inside any section body contains ## Self-Review Instructions -After writing `<translation>`, verify it in two directions. First re-read it in the target language only, without looking at the source; awkward phrasing is easier to notice without source-language anchoring. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing `<review>`; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections. +After writing `<translation>`, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing `<review>`; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections. **Structure** -- Is the heading hierarchy and order, list shape and count, ordered-list start, table shape, and code block content identical to the source? +- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source? - Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source? - Are inline code spans and machine-readable tokens verbatim? - Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one? @@ -169,7 +169,7 @@ After writing `<translation>`, verify it in two directions. First re-read it in **Tone & Style** - Does every sentence read as if originally written by a native technical author? -- Is there any colloquial, casual, overly informal, promotional, or transplanted metaphorical phrasing? +- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing? - Are actors explicit where the target language needs them, without inventing responsibility? **Sentence Structure** @@ -228,9 +228,9 @@ Below are representative examples of common problems and their corrections. Foll - Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。` ### Overly literal → Meaningful rendering -- Source: `awkward phrasing is easier to hear without the source anchoring you` -- Bad: `没有源文锚着,别扭的表述更容易被听出来` -- Good: `不对照原文时,更容易察觉别扭的表达` +- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source` +- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意` +- Good: `不对照原文阅读译文时,更容易察觉别扭的表达` ### Terminology — do not translate what should be kept in English - Source: `typed service seams, and explicit extension points` diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 7a8f73a488..ee0b5cbdd6 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: f1dd0f6635bbb2ed2bbf679fdab2664cef08906d -persistence-catalog.zh.md: 7a0f66b5622fbc9527947019da442b21a1b67b9a +persistence-catalog.md: f44569d3bacec0a832f4b4bca6acf4abb0846a0d +persistence-catalog.zh.md: 21ed29a3da2587a604ec90d201030fd644fc5bd4 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index f1dd0f6635..f44569d3ba 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -474,7 +474,7 @@ Source: [`packages/interaction/permission/src/index.ts:50`](../packages/interact 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) ### `request/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 7a0f66b562..21ed29a3da 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -476,7 +476,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 'plan/mode': { active: boolean } ``` -来源:[`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) ### `request/*` diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml index e538f244b9..63484a6ba0 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml +++ b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/postmortem/0001-acp-default-export-drops-inject.md -0001-acp-default-export-drops-inject.md: 2d36f24fa54814e39345d7fe68792023c2cf0194 -0001-acp-default-export-drops-inject.zh.md: 6ae7d45f58e09f205a5653f7c6d306014d4d393e +0001-acp-default-export-drops-inject.md: f8474bde0b81b24573f813d9a0fb017962751f49 +0001-acp-default-export-drops-inject.zh.md: 1e64f123d1dd0b5e7e81d3a8c4a5e6f77e6411ff diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.md b/docs/postmortem/0001-acp-default-export-drops-inject.md index 2d36f24fa5..f8474bde0b 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.md @@ -18,7 +18,7 @@ The ACP server could not create or load a single session — the two RPCs an edi ## Timeline -- The bridge (RFC 010) landed with a full unit suite (codec, in-memory transport, property-based protocol-shape, failure paths, HMR), a key-gated real-API e2e, and a no-key stdout-purity e2e. All green, 100% coverage. +- The bridge (RFC 010) landed with a full unit suite for the codec, in-memory transport, generated protocol messages, failure paths, and HMR; a key-gated real-API e2e; and a no-key stdout-purity e2e. All green, 100% coverage. - A real Zed session immediately failed on `session/new` with `cannot get property "agents" without inject`. - Investigation initially pursued a Cordis "traceable/shadow" theory (plausible, and the mechanism is real — see Bug #2), then instrumented the actual fiber walk in vendored `reflect.ts` and ran the real subprocess. The trace showed the throw at `apply()` line 179 *at plugin load time*, on the ROOT fiber with no shadow — falsifying the shadow theory for `session/new`. - Root cause #1 found: a stray `export default apply`. Removing it fixed `session/new`. @@ -26,7 +26,7 @@ The ACP server could not create or load a single session — the two RPCs an edi ## Root cause #1 — `export default apply` drops the plugin's `inject` (broke `session/new`) -`packages/acp/acp/src/index.ts` is a *namespace plugin*: it exports `name`, `inject`, `Config`, and `apply` as separate named exports — the same shape as every other plugin in the repo (`invariants`, `llm-deepseek`, `tool-bash`, `tui`, …). But it *also* ended with one extra line no other plugin had: +`packages/acp/acp/src/index.ts` is a *namespace plugin*: it exports `name`, `inject`, `Config`, and `apply` as separate named exports, as every other plugin in the repo does (`invariants`, `llm-deepseek`, `tool-bash`, `tui`, …). But it *also* ended with one extra line no other plugin had: ```ts ignore-check export const name = 'acp' diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md index 6ae7d45f58..1e64f123d1 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md @@ -18,7 +18,7 @@ ACP 服务器无法创建或加载任何一个会话——而这正是编辑器 ## 时间线 -- bridge(RFC 010)落地时附带完整的单元测试套件(codec、内存传输、基于属性的协议形状测试、失败路径、HMR(热模块替换))、一个需要 key 的真实 API e2e 测试,以及一个无需 key 的 stdout 纯净性 e2e 测试。全部绿色,100% 覆盖率。 +- bridge(RFC 010)落地时有一套完整的单元测试,覆盖 codec、内存传输、生成的协议消息、失败路径和 HMR(热模块替换);另有一个需要 key 的真实 API e2e 测试和一个无需 key 的 stdout 纯净性 e2e 测试。全部绿色,100% 覆盖率。 - 真实 Zed 会话在 `session/new` 上立即失败,报错 `cannot get property "agents" without inject`。 - 调查最初追踪了一个 Cordis「traceable/shadow」理论(看似合理,且该机制确实存在——见 Bug #2),随后在 vendor 目录中的 `reflect.ts` 里对实际 fiber 遍历做了插桩,并运行了真实子进程。跟踪结果显示,异常在 `apply()` 第 179 行、*插件加载时*抛出,位于 ROOT fiber 且没有 shadow——推翻了 shadow 理论对 `session/new` 的解释。 - 找到根因 #1:一行多余的 `export default apply`。删除后 `session/new` 修复。 @@ -26,7 +26,7 @@ ACP 服务器无法创建或加载任何一个会话——而这正是编辑器 ## 根因 #1——`export default apply` 丢弃了插件的 `inject`(导致 `session/new` 崩溃) -`packages/acp/acp/src/index.ts` 是一个*命名空间插件*:它将 `name`、`inject`、`Config` 和 `apply` 作为独立的命名导出——与仓库中其他所有插件(`invariants`、`llm-deepseek`、`tool-bash`、`tui` 等)形状相同。但它*还*多了一行其他插件都没有的代码: +`packages/acp/acp/src/index.ts` 是一个*命名空间插件*:它将 `name`、`inject`、`Config` 和 `apply` 作为独立的命名导出,仓库中其他所有插件(`invariants`、`llm-deepseek`、`tool-bash`、`tui` 等)也是如此。但它*还*多了一行其他插件都没有的代码: ```ts ignore-check export const name = 'acp' diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml index b10d882b59..e1e354fc6a 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/postmortem/0002-js-expression-disabled-filesystem-tools.md -0002-js-expression-disabled-filesystem-tools.md: 30ff9d920821a8d55c4bea5f120f1aeeca6634b3 -0002-js-expression-disabled-filesystem-tools.zh.md: 3c18a48d3b7e925a6e75c2d3edb3ec642e72e1b3 +0002-js-expression-disabled-filesystem-tools.md: b2bd37ff2b5a6d01c585514dd83f7fa6604f8945 +0002-js-expression-disabled-filesystem-tools.zh.md: 7a26f8456c13ad22535cd04fdec061dccd2ed85d diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md index 30ff9d9208..b2bd37ff2b 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md @@ -29,7 +29,7 @@ The live confined default did not gain unintended filesystem access. A naive int ## Root cause -The implementation assumed `!!js` applied to an entire Loader entry. Its actual boundary is narrower: `Entry._resolveConfig()` interpolates only `entry.options.config`; `Entry.disabled` tests `entry.options.disabled` without interpolation. The YAML tag was syntactically valid, so loading produced no diagnostic. +The implementation assumed `!!js` applied to an entire Loader entry. It applies only to `entry.options.config`: `Entry._resolveConfig()` interpolates that field, while `Entry.disabled` tests `entry.options.disabled` without interpolation. The YAML tag was syntactically valid, so loading produced no diagnostic. The snapshot framework treated any deterministic transcript as valid behavior. Header pins verified the composed tool schemas, but the filesystem scenarios shared a pin from the default composition and therefore did not independently prove that their required tools were registered. Refresh rewrote the expected stdout and session logs before any semantic assertion rejected missing tools. @@ -42,6 +42,6 @@ The snapshot framework treated any deterministic transcript as valid behavior. H ## Lessons -- A syntactically accepted configuration value is not necessarily evaluated at that location; document and verify interpolation boundaries. +- A syntactically accepted configuration value is not necessarily evaluated at that location; document and verify exactly which fields are interpolated. - A snapshot refresh is fixture production, not correctness review. Semantic impossibilities such as a missing registered tool need assertions independent of the expected output. - Permission controls must describe only the capabilities they actually govern. Composition-time filesystem access cannot follow a runtime bash-only preset safely. diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md index 3c18a48d3b..7a26f8456c 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md @@ -29,7 +29,7 @@ Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader ## 根因 -实现时假设 `!!js` 适用于整个 Loader 配置项。其实际边界更窄:`Entry._resolveConfig()` 仅对 `entry.options.config` 进行插值;`Entry.disabled` 直接测试 `entry.options.disabled`,不经过插值。YAML 标签在语法上合法,因此加载过程不产生任何诊断信息。 +实现时假设 `!!js` 适用于整个 Loader 配置项。实际只有 `entry.options.config` 使用它:`Entry._resolveConfig()` 对该字段进行插值,而 `Entry.disabled` 直接测试 `entry.options.disabled`,不经过插值。YAML 标签在语法上合法,因此加载过程不产生任何诊断信息。 快照框架将任何确定性的 transcript(文本记录)视为有效行为。Header pin 验证了组合后的工具 schema,但文件系统场景共享来自默认组合的 pin,因此未独立证明其所需工具已注册。刷新在任何语义断言拒绝缺失工具之前,就已重写了预期的 stdout 和会话日志。 @@ -42,6 +42,6 @@ Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader ## 教训 -- 语法上被接受的配置值不一定在该位置被求值;应记录并验证插值边界。 +- 语法上被接受的配置值不一定在该位置被求值;应记录并验证具体对哪些字段进行插值。 - 快照刷新是 fixture 的生产过程,不是正确性审查。诸如已注册工具缺失这类语义上不可能的结果,需要独立于预期输出的断言。 - 权限控制只应描述其实际管辖的能力。组合时的文件系统访问无法安全地跟随运行时的 bash-only 预设。 diff --git a/docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml b/docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml index ed77fc2f2e..4272783be3 100644 --- a/docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml +++ b/docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/postmortem/0003-web-agent-gui-feedback-loop.md -0003-web-agent-gui-feedback-loop.md: 13d13a607babfe7f5ddfdb6773c94f973bbef0db -0003-web-agent-gui-feedback-loop.zh.md: 44d4febc30344135932aad2394b3054587feca1d +0003-web-agent-gui-feedback-loop.md: 0d8c07d9aca3305ea6bc85134e8578ae1bd8f387 +0003-web-agent-gui-feedback-loop.zh.md: 07faa3a302d1b124efcbdc57446995aefeb8bd87 diff --git a/docs/postmortem/0003-web-agent-gui-feedback-loop.md b/docs/postmortem/0003-web-agent-gui-feedback-loop.md index 13d13a607b..0d8c07d9ac 100644 --- a/docs/postmortem/0003-web-agent-gui-feedback-loop.md +++ b/docs/postmortem/0003-web-agent-gui-feedback-loop.md @@ -31,7 +31,7 @@ No change in this investigation restarted or modified the read-only 3081 and 308 ## Root cause -The Web assembly had no model-visible identity for the current GUI, canonical URL, or runtime mode. The session cwd correctly represented the user's selected Workspace, but the model mistook that project boundary for the application boundary. No durable contract related the GUI source checkout, built artifacts, serving process, target origin, and browser acceptance. +The Web assembly had no model-visible identity for the current GUI, canonical URL, or runtime mode. The session cwd correctly identified the user's selected Workspace, but the model treated that project directory as the application directory. No durable record related the GUI source checkout, built artifacts, serving process, target origin, and browser acceptance. The wrong startup path looked legitimate because bare Vite returned HTTP 200. `window.__DSH_BOOT__` is injected only by the full host, so transport readiness did not imply application readiness. The first regression test repeated this mistake in another form: a timeout killed Vite and satisfied a nonzero-exit assertion. Live reproduction exposed that false positive. diff --git a/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md b/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md index 44d4febc30..07faa3a302 100644 --- a/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md +++ b/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md @@ -31,7 +31,7 @@ Web agent 修改了 GUI 源码,却不知道由哪个 URL 和进程承载当前 ## 根因 -Web 组合没有向模型提供当前 GUI、规范 URL 或运行模式的身份信息。会话 cwd 正确表示了用户选择的 Workspace,但模型误把这个项目边界当成了应用边界。系统也没有持久约定将 GUI 源码检出目录、构建产物、服务进程、目标 origin 和浏览器验收关联起来。 +Web 组合没有向模型提供当前 GUI、规范 URL 或运行模式的身份信息。会话 cwd 正确标识了用户选择的 Workspace,但模型把这个项目目录当成了应用目录。系统也没有持久记录将 GUI 源码检出目录、构建产物、服务进程、目标 origin 和浏览器验收关联起来。 裸 Vite 返回 HTTP 200,使错误的启动路径看似合理。`window.__DSH_BOOT__` 只由完整宿主注入,因此传输层就绪不代表应用已就绪。首个回归测试以另一种方式重复了同样的错误:超时机制终止 Vite 后,非零退出断言仍会通过。真实复现暴露了这一误报。 diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 99c0f514c4..445d2b5a45 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md -README.md: b1b57466feeee7625b0b5de78e5f9ff220ddef32 -README.zh.md: 492051f013def0f196902d1105f84a5644057f3a +README.md: 7d66cfcf66ffb0bed9430892308934c1f10982f4 +README.zh.md: 90e2b28b15870387539500568bb85b525db63ef6 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index b1b57466fe..7d66cfcf66 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -6,8 +6,8 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | Page | Owns | |---|---| -| [core.md](core.md) | the `packages/core` control spine: the package-by-package loop map, agent creation and ownership (`AgentHandle`), the `Agent` handle with its delivery/cancellation/interception contracts, and the repo-wide type patterns (`…Map → derived-union`, branded ids) | -| [llm-streaming.md](llm-streaming.md) | the `packages/llm` conversation vocabulary — `Message`/`ContentBlock`, the assembled model request, the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` provider contract | +| [core.md](core.md) | how `packages/core` controls the agent loop: the package-by-package loop description, agent creation and ownership (`AgentHandle`), the `Agent` handle's delivery/cancellation/interception contracts, and the repo-wide type patterns (`…Map → derived-union`, branded ids) | +| [llm-streaming.md](llm-streaming.md) | the `packages/llm` conversation types — `Message`/`ContentBlock`, the assembled model request, the `StreamChunk` wire protocol and adapter contract, `BlockAssembler`, and the `LlmAdapter` provider contract | | [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions | | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | | [typert.md](typert.md) | Remote invocation descriptors, lookup/Context declarations, TypeRT registries, and the Host Gateway/Client API boundaries | @@ -23,7 +23,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, and the guarded execution pipeline | | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | -| [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts | +| [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit events, and answerer contracts | | [attachment.md](attachment.md) | durable image identity and metadata, validation inputs, verified reads, and the `AttachmentStore` seam | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles | | [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary | @@ -38,7 +38,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` | | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | -| [tasks.md](tasks.md) | the background-task runtime: branded `TaskId`s, the producer contract, consumer views, `ctx.tasks` service behavior | +| [tasks.md](tasks.md) | the background-task runtime: branded `TaskId`s, the producer contract, consumer views, and `ctx.tasks` service behavior | | [permission.md](permission.md) | the permission-preset layer: `PresetSpec`/`PresetOption`, the derived `custom` state, the log-only `permission/preset` event | | [plan.md](plan.md) | plan mode: the log-only `plan/mode` state, pending-selection flush, `PlanModeConfig`, the `exit_plan_mode` review arc | | [invariants.md](invariants.md) | the runtime-invariant registry: selection `Config`, `InvariantInstaller`/`InvariantFailure`, the empty-companion contract | @@ -47,6 +47,6 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [workspace.md](workspace.md) | the workspace registry: `Workspace`/`WorkspaceId`, registration and resolution, the session `cwd` relationship | | [client-modules.md](client-modules.md) | the web plugin table: `dshClient` declarations, `WebBootGraph` wire composition, the bundle route and index tap | | [session-projection.md](session-projection.md) | the projection seam: `SessionProjectionMap`, the pure `ProjectionDefinition` unit, `ProjectionSnapshot`'s consistent cut, the change feed | -| [telemetry.md](telemetry.md) | the session-telemetry capability seam: `TelemetryRecord`/`TelemetrySeverity`, the `TelemetryBackend` contract, the `telemetry/record` redact waterfall | +| [telemetry.md](telemetry.md) | the outbound session-reporting capability seam: `TelemetryRecord`/`TelemetrySeverity`, the `TelemetryBackend` contract, and the `telemetry/record` redact waterfall | > Type declarations and their JSDoc on these pages are source-equivalent and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Ordinary blocks preserve complete declarations; `public-api` blocks preserve body-stripped public class declarations. Cordis services and events use each page's generated **Cordis surface** section. diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 492051f013..90e2b28b15 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -6,8 +6,8 @@ | 页面 | 负责内容 | |---|---| -| [core.md](core.md) | `packages/core` 控制主干:逐包循环地图、agent 创建与所有权(`AgentHandle`)、`Agent` 句柄及其投递/取消/拦截约定,以及全仓通用类型模式(`…Map → 派生联合`、品牌化 id) | -| [llm-streaming.md](llm-streaming.md) | `packages/llm` 的对话词汇——`Message`/`ContentBlock`、组装完成的模型请求、`StreamChunk` 协议格式(wire format)+ 适配器约定(adapter contract)、`BlockAssembler`、`LlmAdapter` 提供方约定 | +| [core.md](core.md) | `packages/core` 如何控制 agent loop:逐包的循环说明、agent 创建与所有权(`AgentHandle`)、`Agent` 句柄的投递/取消/拦截约定,以及全仓通用类型模式(`…Map → 派生联合`、品牌化 id) | +| [llm-streaming.md](llm-streaming.md) | `packages/llm` 的对话类型——`Message`/`ContentBlock`、组装完成的模型请求、`StreamChunk` wire protocol 和适配器约定(adapter contract)、`BlockAssembler`,以及 `LlmAdapter` 提供方约定 | | [token-meter.md](token-meter.md) | 不可变的标量与位置回放度量,附带已消费日志修订号 | | [scope.md](scope.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | | [typert.md](typert.md) | 远程调用描述符、lookup/Context 声明、TypeRT 注册表,以及 Host Gateway/Client API 边界 | @@ -23,7 +23,7 @@ | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | | [tools.md](tools.md) | `ToolDefinition` 完整字段、schema DSL、`ToolExecution`/`ToolResult`、工具展示 UI 类型,以及受保护的执行流水线 | | [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 | -| [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计与 answerer 约定 | +| [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计事件和 answerer 约定 | | [attachment.md](attachment.md) | 持久图片标识与元数据、校验输入、经校验读取,以及 `AttachmentStore` seam | | [bash.md](bash.md) | bash 执行器 seam:`BashExecRequest`/`Spec`、`BashRunResult`、后台 `BashProcess` 句柄 | | [subprocess.md](subprocess.md) | 子进程 seam:完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 | @@ -38,7 +38,7 @@ | [web.md](web.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、提供方可用性、`WebError` | | [spill.md](spill.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` | | [workflow.md](workflow.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 | -| [tasks.md](tasks.md) | 后台任务运行时:品牌化 `TaskId`、producer 约定、consumer 视图、`ctx.tasks` 服务行为 | +| [tasks.md](tasks.md) | 后台任务运行时:品牌化 `TaskId`、producer 约定、consumer 视图和 `ctx.tasks` 服务行为 | | [permission.md](permission.md) | 权限预设层:`PresetSpec`/`PresetOption`、派生的 `custom` 状态、仅记日志的 `permission/preset` 事件 | | [plan.md](plan.md) | 计划模式:仅记日志的 `plan/mode` 状态、待定选择的冲刷、`PlanModeConfig`、`exit_plan_mode` 审阅流程 | | [invariants.md](invariants.md) | 运行时不变式注册表:选择配置 `Config`、`InvariantInstaller`/`InvariantFailure`、空配套插件约定 | @@ -47,6 +47,6 @@ | [workspace.md](workspace.md) | 工作区注册表:`Workspace`/`WorkspaceId`、注册与解析、与会话 `cwd` 的关系 | | [client-modules.md](client-modules.md) | Web 插件表:`dshClient` 声明、`WebBootGraph` 线上组合、bundle 路由与 index 转换 | | [session-projection.md](session-projection.md) | 投影 seam:`SessionProjectionMap`、纯函数 `ProjectionDefinition` 单元、`ProjectionSnapshot` 的一致切面、变更馈送 | -| [telemetry.md](telemetry.md) | 会话遥测能力 seam:`TelemetryRecord`/`TelemetrySeverity`、`TelemetryBackend` 约定、`telemetry/record` 脱敏 waterfall | +| [telemetry.md](telemetry.md) | 对外会话上报能力 seam:`TelemetryRecord`/`TelemetrySeverity`、`TelemetryBackend` 约定和 `telemetry/record` 脱敏 waterfall | > 这些页面上的类型声明及其 JSDoc 与源码等价,并由 `pnpm run verify-type-equiv` 检查漂移(见 [development.md](../development.md#documenting-types-verbatim-ts-type-equiv))。普通块保留完整声明;`public-api` 块保留去除实现体的公开 class 声明。Cordis 服务与事件使用每页生成的 **Cordis surface** 小节。 diff --git a/docs/subsystems/bash.i18n.yaml b/docs/subsystems/bash.i18n.yaml index dbfc52bf6e..89b7a2feb6 100644 --- a/docs/subsystems/bash.i18n.yaml +++ b/docs/subsystems/bash.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/bash.md -bash.md: d2797c1e3ff73fe8ecb5a053ed4a1d13b13298dd -bash.zh.md: b38a4f332978e94d2945f5fa49b38e8d7df8c9ca +bash.md: b7e7c25ac4e49c34e186ec63f2d84411d71631fc +bash.zh.md: 409a00f8e533f84852d56c1074319a8f0425fb37 diff --git a/docs/subsystems/bash.md b/docs/subsystems/bash.md index d2797c1e3f..b7e7c25ac4 100644 --- a/docs/subsystems/bash.md +++ b/docs/subsystems/bash.md @@ -136,7 +136,7 @@ interface BashRunResult { } ``` -Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The shape is owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`. +Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The fields are owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`. ## File sandbox: `BashSandboxInfo` diff --git a/docs/subsystems/bash.zh.md b/docs/subsystems/bash.zh.md index b38a4f3329..409a00f8e5 100644 --- a/docs/subsystems/bash.zh.md +++ b/docs/subsystems/bash.zh.md @@ -136,7 +136,7 @@ interface BashRunResult { } ``` -每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。该形状归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。 +每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。这些字段归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。 ## 文件沙箱:`BashSandboxInfo` diff --git a/docs/subsystems/code-runtime.i18n.yaml b/docs/subsystems/code-runtime.i18n.yaml index 47783d5f77..1ae9462b90 100644 --- a/docs/subsystems/code-runtime.i18n.yaml +++ b/docs/subsystems/code-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/code-runtime.md -code-runtime.md: 6d3fa7fa72891897155cb9c611c7aa472629de4f +code-runtime.md: a40801313d1adbdd1d601d319c9e5a563db478b6 code-runtime.zh.md: 47516c7d21c24399498a05410ab9a46b61736fb4 diff --git a/docs/subsystems/code-runtime.md b/docs/subsystems/code-runtime.md index 6d3fa7fa72..a40801313d 100644 --- a/docs/subsystems/code-runtime.md +++ b/docs/subsystems/code-runtime.md @@ -36,7 +36,7 @@ interface CodeRunRequest { } ``` -The result reports an error as a **field**, never a rejection of `run()` — reporting a failed program is the caller's job, not an exception path (mirroring `BashExecutor.run`'s resolve-on-failure contract): +The result reports an error as a **field**, never a rejection of `run()` — reporting a failed program is the caller's job, not an exception path (matching `BashExecutor.run`'s resolve-on-failure contract): ```ts type-equiv /** diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index d2fe7303fc..7dc8602b93 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/compaction.md -compaction.md: fdff28ac2ec83966a03050ba8d54fe0ee26c4fc6 -compaction.zh.md: e4227320d9829cac695dc4ba0c5779086f77194b +compaction.md: 8c2a987bc8423841fc23f81bde334c54bb5402c1 +compaction.zh.md: 42ebd7edcf9b64cb9ddcb9a81fef1fc0b2f834b3 diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index fdff28ac2e..8c2a987bc8 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -20,7 +20,7 @@ The lock brackets the **whole** operation: `compact/start` is appended first, th The markers are lock time points, not an exclusive container. An unrelated idle injection can appear between a standalone manual start and end while summarization is pending. The manual path revalidates only its selected positional span, so that injected context survives after the replacement checkpoint. A live unmatched start blocks every entry point; an unmatched start before a newer `session/end-seed` is stale evidence from a prior lifecycle and is ignored. -These variants are merged inside a `declare module '@deepseek-ai/dsh-session/types'` block, so — unlike the top-level types on the other subsystem pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative shapes. +These variants are merged inside a `declare module '@deepseek-ai/dsh-session/types'` block, so — unlike the top-level types on the other subsystem pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative fields. ## `CompactionResult` @@ -85,7 +85,7 @@ type ManualCompactionErrorCode = Pressure compaction runs at serial `agent/pre-step` before request derivation. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and returns a retry action only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. -The Service Definition exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for those edge checks. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. +The Service Definition exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for the tool-call/result pairing checks before and after a seq. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) defines their cache behavior. ## Tool-result pruning outcomes diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index e4227320d9..42ebd7edcf 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -20,7 +20,7 @@ 这些标记表示锁的时间点,而不是排他的容器。摘要等待期间,不相关的空闲注入可以出现在独立的手动 start 与 end 之间。手动路径只重新验证所选位置 span,因此替换检查点之后仍保留该注入上下文。活动的未匹配 start 会阻塞所有入口点;较新 `session/end-seed` 之前的未匹配 start 是先前生命周期留下的陈旧证据,会被忽略。 -这些变体在 `declare module '@deepseek-ai/dsh-session/types'` 块内合并,因此——与其他子系统页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威形状请循源码链接查看。 +这些变体在 `declare module '@deepseek-ai/dsh-session/types'` 块内合并,因此——与其他子系统页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威字段请循源码链接查看。 ## `CompactionResult` @@ -85,7 +85,7 @@ type ManualCompactionErrorCode = 压力压缩在串行 `agent/pre-step` 中运行,先于请求推导。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 -该 Service Definition 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于这些边缘检查。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与遗留结果;其缓存语义由[包约定](../../packages/compact/compact/README.md#tool-pairing-boundaries)规定。 +该 Service Definition 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于检查 seq 之前与之后的工具调用/结果配对。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与遗留结果;[包约定](../../packages/compact/compact/README.md#tool-pairing-boundaries)定义其缓存行为。 ## 工具结果剪枝产出 diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 0b2b87a954..9915a99b63 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: af27484160769156836f377e5b3aba2521280005 -core.zh.md: 12935f4d881f371cfe2c3c5bed85ef88f57ec71a +core.md: 75f55fe5b1837576ba79564b9aee7e289f5f16f4 +core.zh.md: 7cd55b41c8f1358a89c6f35a4d3f733ccd8550ee diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index af27484160..75f55fe5b1 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -2,7 +2,7 @@ English | [中文](core.zh.md) -The **core** subsystem is [`packages/core`](../../packages/core/README.md) — the control spine every composition boots: the event-sourced session log, system-prompt assembly, the tool registry, the agent vocabulary, and the concrete loop that drives them. This page owns what the `agent`/`agent-loop` pair declares — how an agent is created and owned, and the `Agent` handle with its delivery, cancellation, and interception contracts — plus the two type patterns every subsystem follows; the group's dedicated pages and the rest of the folder are indexed in the [subsystems README](README.md). +The **core** subsystem is [`packages/core`](../../packages/core/README.md) — the packages every composition boots: the event-sourced session log, system-prompt assembly, the tool registry, the agent types, and the concrete loop that drives them. This page explains what the `agent`/`agent-loop` pair declares — how an agent is created and owned, and the `Agent` handle's delivery, cancellation, and interception contracts — plus the two type patterns every subsystem follows. The group's dedicated pages and the rest of the folder are indexed in the [subsystems README](README.md). ## The spine, package by package @@ -48,7 +48,7 @@ interface AgentHandle { `CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: session metadata (`meta` — validated `cwd`, fork lineage, seed boundary, origin classification, delegation depth), an optional `seed` replay prefix for forks, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) composes the agent's scoped world while both ids are still unpublished — everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly — and may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id. -`AgentFactory` is the creation contract behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers program against `ctx.agents` without depending on the concrete loop package. The exact `create`/`resume` signatures and their rollback contracts are in the [generated section](#ctxagents--agentregistry) below. +`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. ## The agent handle @@ -206,11 +206,11 @@ The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, che ## Initiating Agent -The process-local initiator carried by `ctx.agents` is the exact `Agent` above, not a separate frame or copied identity. Ambient presence is neither liveness proof nor authorization; the [initiator-scope decision](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns its lifetime and boundary rules. +The process-local initiator carried by `ctx.agents` is the exact `Agent` above, not a separate frame or copied identity. Ambient presence is neither liveness proof nor authorization; the [initiator-scope decision](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md) defines its lifetime and scope rules. ## Interception decisions -Pre-step decisions use the same identified `UserMessage` shape as durable user-role input. The entered batch is authoritative and preserves every message's id and source. Hook bridges map their native decision fields onto this typed result. +Pre-step decisions use the same identified `UserMessage` type as durable user-role input. The entered batch is authoritative and preserves every message's `id` and `source`. Hook bridges map their native decision fields onto this typed result. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -232,7 +232,7 @@ type PreStepDecision = type RequestErrorAction = { kind: 'retry' } | undefined ``` -`agent/pre-step` is the single serial boundary before request derivation. `agent/turn-stopping` runs when a turn has no tool or steering continuation, before one final steering drain. +`agent/pre-step` is the only serial listener chain before request derivation. `agent/turn-stopping` runs when a turn has no tool or steering continuation, before one final steering drain. `agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it): @@ -245,13 +245,13 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`), not stored separately. Every entry carries a monotonic `seq`, a `time`, and a `type`-discriminated `data` payload; surface variants may also list cited earlier events in `sourceEventSeqs` and carry a `surfaceOp`. -The `SessionEvent` envelope's exact conditional shape, the twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The `SessionEvent` envelope's exact conditional fields, the twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` interface, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## `ToolDefinition` -The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional final-content and UI callbacks. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed args), but it is the contract the registry holds and the loop dispatches through. +The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional final-content and UI callbacks. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed arguments), but it is the contract the registry holds and the loop dispatches through. -Its full fields, the `defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` typed schema DSL, the `ToolExecution`/`ToolExecutionResult` waterfall shapes, and the tool-presentation UI vocabulary are on **[tools.md](tools.md)**. +Its full fields, the `defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` typed schema DSL, the `ToolExecution`/`ToolExecutionResult` waterfall types, and the tool-presentation UI types are on **[tools.md](tools.md)**. ## Repo-wide type patterns @@ -259,7 +259,7 @@ Two patterns recur across every subsystem and are documented once, here. ### The `…Map → derived-union` pattern -Almost every extensible sum type in the harness follows one shape: an interface keyed by a discriminant tag (the `…Map`), from which the union is derived with `keyof`. Plugins add variants by **declaration merging** — no edit to the owning package. +Almost every extensible sum type in the harness follows one pattern: an interface keyed by a discriminant tag (the `…Map`), from which the union is derived with `keyof`. Plugins add variants by **declaration merging** — no edit to the owning package. ```ts ignore-check // The pattern, schematically: @@ -293,7 +293,7 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str ### Branded IDs -IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (a `SessionId` cannot be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. +IDs passed between packages are **branded** — structurally strings, but non-interchangeable at the type level (a `SessionId` cannot be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. The `Branded<B>` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 12935f4d88..7cd55b41c8 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -2,7 +2,7 @@ [English](core.md) | 中文 -**核心**子系统即 [`packages/core`](../../packages/core/README.md)——每个组合都会启动的控制主干:事件溯源的会话日志、系统提示词组装、工具注册表、agent 词汇,以及驱动它们的具体循环。本页拥有 `agent`/`agent-loop` 这对包所声明的内容——agent 如何被创建与拥有,以及 `Agent` 句柄及其投递、取消与拦截约定——外加每个子系统都遵循的两个类型模式;该组的专属页面与目录其余部分见[子系统 README](README.md)。 +**核心**子系统即 [`packages/core`](../../packages/core/README.md),包含每个组合都会启动的包:事件溯源的会话日志、系统提示词组装、工具注册表、agent 类型,以及驱动它们的具体循环。本页说明 `agent`/`agent-loop` 这对包所声明的内容:agent 如何被创建与拥有,以及 `Agent` 句柄的投递、取消与拦截约定;本页还说明每个子系统都遵循的两个类型模式。该组的专属页面与目录其余部分见[子系统 README](README.md)。 ## 主干逐包速览 @@ -50,7 +50,7 @@ interface AgentHandle { `CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:会话元数据(`meta`——已校验的 `cwd`、fork 谱系、seed 边界、来源分类、委派深度)、fork 用的可选 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应物:`resumeSessionId`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在两个 id 都尚未发布时组装 agent 的作用域世界——凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在——并可返回一个在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose 都会回滚事务,两个 id 均不发布。 -`AgentFactory` 是注册表背后的创建约定:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方面向 `ctx.agents` 编程,无需依赖具体循环包。确切的 `create`/`resume` 签名及其回滚约定见下方[生成区块](#ctxagents--agentregistry)。 +`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 <a id="the-agent-handle"></a> @@ -206,17 +206,17 @@ type AgentCancelCause = cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有者会将它复制到仅运行时的 `AbortSignal.reason`;signal 不授予协作监听器任何分类权限。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录谁请求了取消,应使用单独的持久事件,而不是让终态结果承担额外含义。 -[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)约定。轮次和步骤边界是持久会话事件,而不是 agent emit。 +[事件分类](../architecture.md#event)负责 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)约定。轮次和步骤边界是持久会话事件,而不是 agent emit。 <a id="initiating-agent"></a> ## 发起 Agent -`ctx.agents` 携带的进程本地 initiator 就是上面的确切 `Agent`,不是单独的 frame 或复制的标识。环境中存在该值既不能证明存活,也不代表授权;其生命周期与边界规则由 [initiator 作用域决策](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)规定。 +`ctx.agents` 携带的进程本地 initiator 就是上面的确切 `Agent`,不是单独的 frame 或复制的标识。环境中存在该值既不能证明存活,也不代表授权;[initiator 作用域决策](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)定义其生命周期和作用域规则。 ## 拦截决策 -pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessage` 形状。进入步骤的批次具有权威性,并保留每条消息的 id 和 source。钩子桥接层把其原生决策字段映射到这一类型化结果上。 +pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessage` 类型。进入步骤的批次具有权威性,并保留每条消息的 `id` 和 `source`。钩子桥接层把其原生决策字段映射到这一类型化结果上。 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -238,7 +238,7 @@ type PreStepDecision = type RequestErrorAction = { kind: 'retry' } | undefined ``` -`agent/pre-step` 是请求推导前唯一的串行边界。`agent/turn-stopping` 在轮次没有工具或 steering(中途引导)后续时运行,先于最后一次 steering 排空。 +`agent/pre-step` 是请求推导前唯一的串行监听器链。`agent/turn-stopping` 在轮次没有工具或 steering(中途引导)后续时运行,先于最后一次 steering 排空。 `agent/session-start` 携带 `SessionStartSource`(会话生命周期为何开始;桥接层据此匹配其 SessionStart): @@ -251,13 +251,13 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' `Session` 是一份类型化 `SessionEvent` 的**仅追加日志**——唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`),而非单独存储。每个条目携带单调的 `seq`、`time` 与按 `type` 判别的 `data` payload;surface 变体还可以在 `sourceEventSeqs` 中列出被引用的较早事件,并携带 `surfaceOp`。 -`SessionEvent` 信封的确切条件形状、十二种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 +`SessionEvent` 信封的确切条件字段、十二种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` 接口、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 ## `ToolDefinition` -唯一属于核心的流水线编写类型:每个已注册工具*是什么*——一个面向模型的 `ToolSchema` 加上一个 `execute` 函数,以及可选的最终内容回调与 UI 回调。工具作者很少手动构造它(`defineTool` DSL 会用类型化参数构建),但它是注册表持有、循环分发所经过的约定。 +唯一属于核心的流水线编写类型:每个已注册工具*是什么*——一个面向模型的 `ToolSchema` 加上一个 `execute` 函数,以及可选的最终内容回调与 UI 回调。工具作者很少手动构造它(`defineTool` DSL 会使用类型化参数构建),但它是注册表存储并由循环用于分发的约定。 -其完整字段、`defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` 类型化 schema DSL、`ToolExecution`/`ToolExecutionResult` waterfall 形状,以及工具展示 UI 词汇在 **[tools.md](tools.md)** 中。 +其完整字段、`defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` 类型化 schema DSL、`ToolExecution`/`ToolExecutionResult` waterfall 类型,以及工具展示 UI 类型都在 **[tools.md](tools.md)** 中。 ## 全仓通用类型模式 @@ -265,7 +265,7 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ### `…Map → derived-union` 模式 -harness 中几乎所有可扩展的和类型都遵循同一形状:一个以判别标签为键的接口(`…Map`),联合类型由 `keyof` 派生。插件通过**声明合并**添加变体——无需修改拥有该类型的包。 +harness 中几乎所有可扩展的和类型都遵循同一模式:一个以判别标签为键的接口(`…Map`),联合类型由 `keyof` 派生。插件通过**声明合并**添加变体——无需修改拥有该类型的包。 ```ts ignore-check // The pattern, schematically: @@ -301,7 +301,7 @@ declare module '@deepseek-ai/dsh-llm' { ### 品牌化 ID -跨越包边界的 ID 都经过**品牌化**——结构上是字符串,但在类型层面不可互换(不能把 `SessionId` 传给需要 `CallId` 的位置)。每种类型通过各自的工厂构造;比较、日志记录和 JSON 行为与普通字符串相同。 +在包之间传递的 ID 都经过**品牌化**——结构上是字符串,但在类型层面不可互换(不能把 `SessionId` 传给需要 `CallId` 的位置)。每种类型通过各自的工厂构造;比较、日志记录和 JSON 行为与普通字符串相同。 `Branded<B>` 原语位于独立的纯类型包 [dsh-brand](../../packages/util/brand) 中(没有运行时代码,也不依赖 Harness 包),因此任何包都能品牌化其拥有的 id,而无需依赖无关的能力包。 diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 7e9e057c75..c0ca84eb9f 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/credentials.md -credentials.md: 0bc2224ac039addc795d3806e8c85004f2bb84a7 -credentials.zh.md: f236b0b2daef85784308f10dbcfc67db84c42234 +credentials.md: 1d168999d338ba43c92150b171f89b610850f694 +credentials.zh.md: af92f17b10c80d3f78c61e306c526a165e48381b diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index 0bc2224ac0..1d168999d3 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -8,7 +8,7 @@ Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credent ## Identity -A reference names one credential as a POSIX-style environment-variable name. The brand keeps references from mixing with other cross-boundary strings; construction validates the shell-identifier shape. +A reference names one credential as a POSIX-style environment-variable name. The brand prevents callers from mixing credential references with other strings passed between packages or processes; construction validates the shell-identifier syntax. ```ts type-equiv /** Nominal reference to one credential: a POSIX-style environment-variable name. */ diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index f236b0b2da..af92f17b10 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -8,7 +8,7 @@ ## 标识 -引用以 POSIX 风格环境变量名命名一条凭据。brand 使引用不与其他跨边界字符串混用;构造时校验 shell 标识符形态。 +引用以 POSIX 风格环境变量名命名一条凭据。brand 防止调用方将凭据引用与在包或进程之间传递的其他字符串混用;构造时校验 shell 标识符语法。 ```ts type-equiv /** Nominal reference to one credential: a POSIX-style environment-variable name. */ diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index fac61bde63..34d90d18f2 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: 3c154af0fa4ee6d28f2379c5392dcc9b194a2c99 +filesystem.zh.md: 5b8a136af156c63de72e260d5859d8bac7b827c5 diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index e0edad514b..3c154af0fa 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -4,7 +4,7 @@ English | [中文](filesystem.zh.md) The optional filesystem capability has four parts: [dsh-fs](../../packages/fs/fs) owns `ctx.fs` and atomic text operations with optional guards, [dsh-fs-local](../../packages/fs/fs-local) implements local disk, [dsh-fs-policy](../../packages/fs/fs-policy) records observed presence or absence and adds freshness rules through events rather than a service, and [dsh-tool-fs](../../packages/fs/tool-fs) directly executes model-facing read/write/edit calls and renders windows. It is outside the agent-loop spine; alternate backends do not change policy or tool schemas. -The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-fs-policy` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. +`dsh-fs-policy` is optional. Without it, the `FileSystem` Service Definition, a provider, and the `dsh-tool-fs` Consumer form the complete, unconstrained filesystem seam: `write` unconditionally creates or overwrites, and `edit` unconditionally replaces literal text. The policy plugin changes these operations by deciding the `fs/*` waterfalls. Removing it does not break the tool because the tool calls `ctx.fs` and dispatches events; it does not call policy methods. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/fs-policy/src/types.ts`](../../packages/fs/fs-policy/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts). @@ -113,7 +113,7 @@ interface FsDirEntry { ## Write and edit guards (provider contract) -Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`, including a target that appears after the provider's initial probe because publication itself must be no-replace; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape. +Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`, including a target that appears after the provider's initial probe because publication itself must be no-replace; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit both use the same optional `expected` field. ```ts type-equiv /** @@ -196,15 +196,15 @@ type FsObservation = ## Execution context (policy plugin) -The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-fs-policy` import the tool, agent, or session packages. +The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` has the required fields, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-fs-policy` import the tool, agent, or session packages. ```ts type-equiv /** * Minimal structural view of a tool execution the policy plugin needs to derive - * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies - * this shape, so the tool passes its `exec` straight through as the opaque - * `object` actor on the `fs/*` events; this plugin narrows that actor to this - * shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` contains + * these fields, so the tool passes its `exec` straight through as the opaque + * `object` actor on the `fs/*` events; this plugin narrows that actor to + * `FsPolicyExec` without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. * * The owner is `agent.session` when present. It is treated as an opaque object * identity (a `WeakMap` key); this package never reads any of its fields. @@ -220,7 +220,7 @@ interface FsPolicyExec { ## Read outcome (consumer / read rendering) -A text read is bounded by line window, byte cap, and backend limits. After the byte cap is reached, scanning continues without retaining more lines so `totalLines` remains exact. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits a present `fs/observed` with the stat's version), so any windowed read can authorize a later write/edit when the file is unchanged. A metadata miss emits an absent observation before the tool returns `FS_NOT_FOUND`, allowing a later guarded write to recreate an externally deleted target without authorizing edit. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. +A text read is bounded by line window, byte cap, and backend limits. After the byte cap is reached, scanning continues without retaining more lines so `totalLines` remains exact. The result the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits a present `fs/observed` directly with the stat's version), so any windowed read can authorize a later write/edit when the file is unchanged. A metadata miss emits an absent observation before the tool returns `FS_NOT_FOUND`, allowing a later guarded write to recreate an externally deleted target without authorizing edit. `dsh-tool-fs`, the executor that owns the read, implements read windowing and constructs this result; the policy plugin does not. ```ts type-equiv /** Outcome of a bounded text read — what {@link formatReadOutput} renders. */ diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 81c2d87b6a..5b8a136af1 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -4,7 +4,7 @@ 可选的文件系统能力由四个部分组成:[dsh-fs](../../packages/fs/fs) 拥有 `ctx.fs` 以及带可选守卫的原子文本操作;[dsh-fs-local](../../packages/fs/fs-local) 实现本地磁盘后端;[dsh-fs-policy](../../packages/fs/fs-policy) 记录观测到的存在或缺失状态,并通过事件(而非服务)添加新鲜度规则;[dsh-tool-fs](../../packages/fs/tool-fs) 直接执行面向模型的 read/write/edit 调用并渲染窗口。它位于 agent loop(智能体循环)主干之外;替换后端不会改变策略或工具 schema。 -该模型是**加法式而非减法式**的:`ctx.fs` 本身就是一个完整、无约束的文本存储 seam(`write` 无条件创建或覆盖,`edit` 无条件替换字面文本)。`dsh-fs-policy` 是一个插件,通过裁决 `fs/*` waterfall(瀑布式事件)在上层*叠加*策略;移除它只会暴露裸提供方,而不会破坏工具,因为工具与策略之间没有方法级耦合。加载了 `dsh-tool-fs` 的部署通常也应加载 `dsh-fs-policy`,使默认行为为「先读后写/编辑」。 +`dsh-fs-policy` 是可选插件。没有该插件时,`FileSystem` 服务定义、一个提供方和 `dsh-tool-fs` 消费方组成完整且不受约束的文件系统 seam:`write` 无条件创建或覆盖,`edit` 无条件替换字面文本。策略插件通过裁决 `fs/*` waterfall(瀑布式事件)来改变这些操作。移除该插件不会破坏工具,因为工具调用 `ctx.fs` 并分发事件,而不调用策略方法。加载了 `dsh-tool-fs` 的部署通常也应加载 `dsh-fs-policy`,使默认行为为「先读后写/编辑」。 提供方源码:[`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) 与 [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts)。策略源码:[`packages/fs/fs-policy/src/types.ts`](../../packages/fs/fs-policy/src/types.ts)。读取渲染源码:[`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts)。 @@ -113,7 +113,7 @@ interface FsDirEntry { ## 写入与编辑守卫(提供方约定) -`writeText` 和 `editText` 的版本守卫都是可选的:省略守卫时执行无条件的裸提供方变更,提供守卫时则执行相应的条件检查。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;即使目标在提供方初始探测后才出现,也必须拒绝,因为发布操作本身不得替换。`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 共享同一个对称的 `expected?` 形状。 +`writeText` 和 `editText` 的版本守卫都是可选的:省略守卫时执行无条件的裸提供方变更,提供守卫时则执行相应的条件检查。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;即使目标在提供方初始探测后才出现,也必须拒绝,因为发布操作本身不得替换。`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 都使用同一个可选的 `expected` 字段。 ```ts type-equiv /** @@ -196,15 +196,15 @@ type FsObservation = ## 执行上下文(策略插件) -策略插件只需要足够的执行上下文,通过收窄 `fs/*` 事件携带的不透明 `object` actor 来推导观测状态的所有者。`ToolExecution` 满足此形状,因此 `dsh-tool-fs` 将其执行对象作为 actor 直接传递,而无需让 `dsh-fs-policy` 导入工具、agent 或会话包。 +策略插件只需要足够的执行上下文,通过收窄 `fs/*` 事件携带的不透明 `object` actor 来推导观测状态的所有者。`ToolExecution` 包含必需的字段,因此 `dsh-tool-fs` 将其执行对象作为 actor 直接传递,而无需让 `dsh-fs-policy` 导入工具、agent 或会话包。 ```ts type-equiv /** * Minimal structural view of a tool execution the policy plugin needs to derive - * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies - * this shape, so the tool passes its `exec` straight through as the opaque - * `object` actor on the `fs/*` events; this plugin narrows that actor to this - * shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` contains + * these fields, so the tool passes its `exec` straight through as the opaque + * `object` actor on the `fs/*` events; this plugin narrows that actor to + * `FsPolicyExec` without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. * * The owner is `agent.session` when present. It is treated as an opaque object * identity (a `WeakMap` key); this package never reads any of its fields. @@ -220,7 +220,7 @@ interface FsPolicyExec { ## 读取结果(消费方 / 读取渲染) -文本读取受行窗口、字节上限和后端限制约束。达到字节上限后,扫描仍会继续,但不再保留更多行,因此 `totalLines` 仍为精确值。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具以 stat 的版本 emit 表示存在的 `fs/observed`),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。元数据未命中时,工具会在返回 `FS_NOT_FOUND` 前 emit 缺失观测,使后续带防护的写入可以重新创建外部删除的目标,但不会授权 edit。读取窗口化与此结果形状位于 `dsh-tool-fs`(拥有读取操作的执行器)中,而非策略插件中。 +文本读取受行窗口、字节上限和后端限制约束。达到字节上限后,扫描仍会继续,但不再保留更多行,因此 `totalLines` 仍为精确值。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具 emit 表示存在的 `fs/observed`,并直接携带 stat 的版本),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。元数据未命中时,工具会在返回 `FS_NOT_FOUND` 前 emit 缺失观测,使后续带守卫的写入可以重新创建外部删除的目标,但不会授权 edit。拥有读取操作的执行器 `dsh-tool-fs` 实现读取窗口化并构造该结果;策略插件不执行这些操作。 ```ts type-equiv /** Outcome of a bounded text read — what {@link formatReadOutput} renders. */ diff --git a/docs/subsystems/goal.i18n.yaml b/docs/subsystems/goal.i18n.yaml index cdd932726e..f610740a21 100644 --- a/docs/subsystems/goal.i18n.yaml +++ b/docs/subsystems/goal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/goal.md -goal.md: 6f54a5261cb44c3fda389e37cb689a00061ab241 -goal.zh.md: ea5a0fe7923648aead5e6f54ab31162a7abdb954 +goal.md: 93837e15ee3244671cacaed26983e8e4ca38bd56 +goal.zh.md: 8ea138bc80a400120b065f616182acb0906c68dd diff --git a/docs/subsystems/goal.md b/docs/subsystems/goal.md index 6f54a5261c..93837e15ee 100644 --- a/docs/subsystems/goal.md +++ b/docs/subsystems/goal.md @@ -2,7 +2,7 @@ English | [中文](goal.zh.md) -Types shared by the event-sourced goal domain and its policy consumers. The [goal-domain Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the persistence and activation decisions; this page records the literal shapes from [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts). +Types shared by the event-sourced goal service and its policy consumers. The [goal-domain Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the persistence and activation decisions; this page records the exact fields and variants from [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts). ## Identity and lifecycle @@ -142,7 +142,7 @@ interface GoalChanged { ## Service behavior -[`GoalService`](../../packages/goal/goal/src/index.ts) resolves creation defaults, folds strict replay from durable `goal/change` events, enforces exact-live-agent identity and compare-and-set mutations, and emits contained `goal/changed` notifications. The package [README](../../packages/goal/goal/README.md) owns the callable and model-visible contract. +[`GoalService`](../../packages/goal/goal/src/index.ts) resolves creation defaults, folds strict replay from durable `goal/change` events, enforces exact-live-agent identity and compare-and-set mutations, and emits contained `goal/changed` notifications. The package [README](../../packages/goal/goal/README.md) defines the callable API and model-visible contract. <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> diff --git a/docs/subsystems/goal.zh.md b/docs/subsystems/goal.zh.md index ea5a0fe792..8ea138bc80 100644 --- a/docs/subsystems/goal.zh.md +++ b/docs/subsystems/goal.zh.md @@ -2,7 +2,7 @@ [English](goal.md) | 中文 -事件溯源目标领域及其策略消费方共享的类型。[目标领域 Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md)负责记录持久化与激活决策;本页记录 [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts) 中的字面形态。 +事件溯源目标服务及其策略消费方共享的类型。[目标领域 Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md)负责记录持久化与激活决策;本页记录 [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts) 中的确切字段和变体。 ## 标识与生命周期 @@ -142,7 +142,7 @@ interface GoalChanged { ## 服务行为 -[`GoalService`](../../packages/goal/goal/src/index.ts) 解析创建默认值、从持久 `goal/change` 事件执行严格回放折叠、校验确切的活跃 agent 身份、以比较并设置方式执行变更,并发出 `goal/changed` 通知;监听器故障会被隔离。包 [README](../../packages/goal/goal/README.md) 负责记录可调用约定和面向模型的约定。 +[`GoalService`](../../packages/goal/goal/src/index.ts) 解析创建默认值、从持久 `goal/change` 事件执行严格回放折叠、校验确切的活跃 agent 身份、以比较并设置方式执行变更,并发出 `goal/changed` 通知;监听器故障会被隔离。包 [README](../../packages/goal/goal/README.md) 定义可调用 API 和面向模型的约定。 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> diff --git a/docs/subsystems/http-server.i18n.yaml b/docs/subsystems/http-server.i18n.yaml index 6f61102676..4c3975588b 100644 --- a/docs/subsystems/http-server.i18n.yaml +++ b/docs/subsystems/http-server.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/http-server.md -http-server.md: b9795fe98b432b6ef5f7d01a4d3e115c809fe642 -http-server.zh.md: 8d55c48ae2882bd471ca4e64e011c7d79c4ebb40 +http-server.md: 232755ba6b77ec940ebe2cb7f19fd962018300f1 +http-server.zh.md: 59778e6455d4f32e30c3e77c88cd76a2ecc1845f diff --git a/docs/subsystems/http-server.md b/docs/subsystems/http-server.md index b9795fe98b..232755ba6b 100644 --- a/docs/subsystems/http-server.md +++ b/docs/subsystems/http-server.md @@ -2,7 +2,7 @@ English | [中文](http-server.zh.md) -[dsh-host-webserver](../../packages/host/webserver) is the web-shape HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.httpServer`, a named-route registry, index.html transform taps, and a single claimable fallback seat. It is not part of the agent-loop spine and not a capability seam — it knows no harness concepts, and every feature surface (the `/api` bridge, plugin bundles, the HMR event stream) is a route some other plugin registers ([layering note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). Web (browser) shape only: Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. +[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.httpServer`, a named-route registry, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -42,7 +42,7 @@ interface Config { ## The service -`HttpServerService` (`ctx.httpServer`) listens immediately on activation; a listen failure (EADDRINUSE…) throws out of init — a FAILED fiber the boot's fail-loud sweep reports. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws, because route patterns are a composition-level contract and a collision is a misconfiguration. `tapIndex(transform)` adds a pure html-to-html transform applied to every index response — `/` and each SPA fallback — in registration order; [dsh-client-modules](../../packages/client/modules) uses it to inject the boot manifest. `port` reads the listening port, the OS-assigned value when `config.port` is 0. +`HttpServerService` (`ctx.httpServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. `tapIndex(transform)` adds a pure html-to-html transform applied to every index response — `/` and each SPA fallback — in registration order; [dsh-client-modules](../../packages/client/modules) uses it to inject the boot manifest. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md). @@ -58,7 +58,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.httpServer` — `HttpServerService` -The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the fallback seat answers anything not yet claimed during the boot window — 404 until its owner registers). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports. +The browser HTTP carrier service. Activation listens immediately. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. A listen failure rejects initialization, and the boot process reports the failed fiber. ```ts cordis-catalog /** @@ -104,5 +104,5 @@ tapIndex(transform: (html: string) => string): () => void applyIndexTaps(html: string): string ``` -Source: [`packages/host/webserver/src/index.ts:60`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:59`](../../packages/host/webserver/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/http-server.zh.md b/docs/subsystems/http-server.zh.md index 8d55c48ae2..59778e6455 100644 --- a/docs/subsystems/http-server.zh.md +++ b/docs/subsystems/http-server.zh.md @@ -2,7 +2,7 @@ [English](http-server.md) | 中文 -[dsh-host-webserver](../../packages/host/webserver) 是 GUI 宿主 web 形态的 HTTP 载体:单个提供 `ctx.httpServer` 的 `node:http` 插件,由具名路由注册表、index.html 转换挂点与单一可认领的回退席位组成。它不属于 agent loop(智能体循环)主干,也不是能力 seam:它不了解任何 harness 概念,每个功能表面(`/api` 桥接、插件 bundle、HMR(热模块替换)事件流)都是由其他插件注册的一条路由([分层说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md))。仅限 web(浏览器)形态:Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,不经过本服务器。 +[dsh-host-webserver](../../packages/host/webserver) 是 GUI 宿主的浏览器 HTTP 载体:它是一个提供 `ctx.httpServer` 的 `node:http` 插件,包含具名路由注册表、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -42,7 +42,7 @@ interface Config { ## 服务 -`HttpServerService`(`ctx.httpServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会从 init 抛出,形成一个 FAILED fiber,由启动的大声失败 sweep 上报。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`tapIndex(transform)` 添加一个纯的 html 到 html 转换,按注册顺序应用于每个 index 响应(`/` 和每次 SPA 回退);[dsh-client-modules](../../packages/client/modules) 用它注入启动 manifest(元数据清单)。`port` 读取监听端口,`config.port` 为 0 时读到的是操作系统分配的值。 +`HttpServerService`(`ctx.httpServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`tapIndex(transform)` 添加一个纯的 html 到 html 转换,按注册顺序应用于每个 index 响应(`/` 和每次 SPA 回退);[dsh-client-modules](../../packages/client/modules) 用它注入启动 manifest(元数据清单)。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。 处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。该包(package)从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.md) 中。 @@ -58,7 +58,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.httpServer` — `HttpServerService` -The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the fallback seat answers anything not yet claimed during the boot window — 404 until its owner registers). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports. +The browser HTTP carrier service. Activation listens immediately. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. A listen failure rejects initialization, and the boot process reports the failed fiber. ```ts cordis-catalog /** @@ -104,5 +104,5 @@ tapIndex(transform: (html: string) => string): () => void applyIndexTaps(html: string): string ``` -Source: [`packages/host/webserver/src/index.ts:60`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:59`](../../packages/host/webserver/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index dfb482ba7e..052ec4fedd 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 9f92052d411e3bd4256db63df54eba7f4e313b18 -llm-streaming.zh.md: ab5540c9e7adce70f0462fb478e8f674d1f6bba4 +llm-streaming.md: 8ae4e8b376b4c4221e6179eb719fcf162f3031e4 +llm-streaming.zh.md: 7d244ab882521a90217873fc3cdee12cd5232db8 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 9f92052d41..8ae4e8b376 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -2,7 +2,7 @@ English | [中文](llm-streaming.zh.md) -The conversation and streaming vocabulary of [`packages/llm`](../../packages/llm/README.md): the `Message`/`ContentBlock` shapes every request and durable history share, the fully-assembled model request, the raw `StreamChunk` protocol, the adapter contract every adapter must obey, and the shared assembler. The [core spine](core.md) holds and logs these values on every turn; this page declares them. +The conversation and streaming types from [`packages/llm`](../../packages/llm/README.md): the `Message`/`ContentBlock` variants every request and durable history share, the fully assembled model request, the raw `StreamChunk` protocol, the adapter contract every adapter must implement, and the shared assembler. The [core packages](core.md) hold and log these values on every turn; this page declares them. Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) @@ -79,12 +79,12 @@ interface MessageSourceMap { } ``` -Producer identity and content shape are independent. `kind` answers *who produced this*; the optional `form` a producer mixes in answers *what shape of information it is*, so several producers may share one presentation and one producer may emit more than one shape over a session. The vocabulary is semantic and grows one value at a time; an absent or unrecognized value is the documented default, presented as opaque content: +Producer identity and presentation form are independent. `kind` answers *who produced this*; the optional `form` answers *what kind of information this is*, and consumers decide how to present it. Several producers may share one form, and one producer may emit more than one form over a session. The values are semantic and grow one at a time; an absent or unrecognized value uses the documented default and is presented as opaque content: ```ts type-equiv /** - * What SHAPE of information a producer-supplied context carries, declared by - * the producer beside the source fields it supplied. + * The kind of information in producer-supplied context, declared by the + * producer beside its provenance. * * `MessageSource.kind` answers *who produced this*; `form` answers *what kind * of thing it is*, and the two axes are deliberately independent — several @@ -126,10 +126,10 @@ interface ContextSnapshotSection { ```ts type-equiv /** * Producer-declared {@link ContextForm} and the fields that form requires, - * mixed into the source shapes that carry one. + * mixed into the source types that carry one. * - * Discriminated by `form` so a producer cannot declare a shape without the - * facts that shape is presented from: a `notice` must record its one-line + * Discriminated by `form` so a producer cannot select a form without the + * fields needed to present it: a `notice` must record its one-line * account, a `snapshot` its sections. Omitting `form` stays valid — an * undeclared context is the documented default. */ @@ -184,13 +184,13 @@ type StreamChunk = Every thrown or in-band final-adapter failure normalizes to one serializable provider-neutral payload. `providerRetryAfterMs` is a validated positive delay requested by the provider, not a retry decision; `ProviderRequestId` is an opaque branded string for diagnostics. ```ts type-equiv -/** Serializable provider-boundary facts; policy decides whether they are retryable. */ +/** Serializable provider or transport failure facts; policy decides whether they are retryable. */ interface LlmFailure { /** Human-readable provider or transport failure. */ readonly message: string /** Stable provider-neutral machine-routing code. */ readonly code: string - /** HTTP status observed at the provider boundary, when available. */ + /** HTTP status returned by the provider, when available. */ readonly status?: number /** Provider-requested delay in milliseconds, when valid and available. */ readonly providerRetryAfterMs?: number @@ -205,7 +205,7 @@ Every adapter MUST obey these, and every consumer may rely on them: - **`usage` before `finish`, nothing after `finish`.** Defer both to the provider's end-of-stream marker so a trailing usage-only chunk can't violate the ordering. - **Tool-call `arguments` stay raw JSON strings end-to-end.** Partial fragments stream via `argumentsDelta`; a provider that hands back parsed objects re-stringifies at `block-end`. -- **Two sanctioned error paths, one fact shape.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. The final adapter boundary preserves the exact thrown `Error` object and associates immutable facts plus the serving registration's immutable retry policy with that call; the agent loop closes the failed step and offers the error, facts, immutable prior-retried facts, serving policy, and turn signal to `agent/request-error`. A handling listener returns `{ kind: 'retry' }` after its awaited repair; absent recovery the structured failure becomes the turn error, and no normal assistant message or tool side effect is committed for that attempt. +- **Two sanctioned error paths, one `LlmFailure` type.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. After the call selects its adapter, the stream preserves the exact thrown `Error` object and associates immutable facts plus the serving registration's immutable retry policy with that call; the agent loop closes the failed step and offers the error, facts, immutable prior-retried facts, serving policy, and turn signal to `agent/request-error`. A handling listener returns `{ kind: 'retry' }` after its awaited repair; absent recovery the structured failure becomes the turn error, and no normal assistant message or tool side effect is committed for that attempt. - **One adapter call is one provider attempt.** Adapters disable library retries. Agent-level recovery opens another durable numbered turn; direct `ctx.llm.stream()` callers remain single-attempt. - **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`. - **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text. @@ -215,7 +215,7 @@ Every adapter MUST obey these, and every consumer may rely on them: ## `ResolvedRetryPolicy` -Provider configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. `LlmService.providerRetryPolicy(provider)` returns the currently registered value and supplies normal defaults when the adapter omits one; `llmRetryPolicyOf(stream)` returns the exact serving registration's captured value after that call enters its final adapter boundary, so later route disposal or replacement cannot change an in-flight failure's recovery policy. The [generated config catalog](../config-catalog.md) owns the optional input shapes. +Provider configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. `LlmService.providerRetryPolicy(provider)` returns the currently registered value and supplies normal defaults when the adapter omits one; `llmRetryPolicyOf(stream)` returns the value captured from the serving registration after the call selects that registration, so later route disposal or replacement cannot change an in-flight failure's recovery policy. The [generated config catalog](../config-catalog.md) lists the optional input fields. ## `AppIdentity` — app attribution @@ -533,7 +533,7 @@ interface ToolSchema { } ``` -The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` that produces it (schema + `execute`) is on [tools.md](tools.md). +The model-facing `ToolSchema` is the wire type; the registered `ToolDefinition` that produces it (schema + `execute`) is on [tools.md](tools.md). A provider a surface is still drafting has no route and no catalog, so interrogation is described separately: the request carries the draft the user is editing, and the reply is candidates a surface may adopt rather than a catalog it must serve. @@ -590,7 +590,7 @@ The loop builds each request from logged state. `EpochHeader` records call confi `agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus the fields supplied by adapter defaults under the turn signal. The prepared call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. -On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request. +On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history. The logged request snapshot ends with the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request. FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution). @@ -652,8 +652,8 @@ interface PreparedLlmCall { /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch - * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove the headers are added in the wire request or library header hook. The direct-fetch + * DeepSeek and library-backed pi-ai adapters meet this contract through different internals. */ declare abstract class LlmAdapter { /** diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index ab5540c9e7..7d244ab882 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -2,7 +2,7 @@ [English](llm-streaming.md) | 中文 -[`packages/llm`](../../packages/llm/README.md) 的对话与流式输出词汇:每个请求与持久历史共享的 `Message`/`ContentBlock` 形状、完整组装的模型请求、原始 `StreamChunk` 协议、每个适配器必须遵守的适配器约定(adapter contract),以及共享的 assembler。[核心主干](core.md)在每个轮次持有并记录这些值;本页声明它们。 +[`packages/llm`](../../packages/llm/README.md) 提供对话与流式输出类型:每个请求和持久历史共用的 `Message`/`ContentBlock` 变体、完整组装的模型请求、原始 `StreamChunk` 协议、每个适配器必须实现的适配器约定(adapter contract),以及共享的 assembler。[核心包](core.md)在每个轮次持有并记录这些值;本页声明它们。 源码:[`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) @@ -79,12 +79,12 @@ interface MessageSourceMap { } ``` -生产方标识与内容形态相互独立。`kind` 回答「由谁产生」;生产方可选混入的 `form` 回答「这是何种形态的信息」,因此多个生产方可以共用一种呈现,一个生产方在一次会话中也可以发出多种形态。该词汇表是语义的,逐个取值增长;未声明或无法识别的取值是有文档的默认,按不透明内容呈现: +生产方标识与呈现形式相互独立。`kind` 回答「由谁产生」;可选的 `form` 回答「这是什么类型的信息」,消费方决定如何呈现。多个生产方可以共用一种 `form`,一个生产方在一次会话中也可以发出多种 `form`。这些取值描述语义,并逐个增加;未声明或无法识别的值使用文档规定的默认值,按不透明内容呈现: ```ts type-equiv /** - * What SHAPE of information a producer-supplied context carries, declared by - * the producer beside the source fields it supplied. + * The kind of information in producer-supplied context, declared by the + * producer beside its provenance. * * `MessageSource.kind` answers *who produced this*; `form` answers *what kind * of thing it is*, and the two axes are deliberately independent — several @@ -126,10 +126,10 @@ interface ContextSnapshotSection { ```ts type-equiv /** * Producer-declared {@link ContextForm} and the fields that form requires, - * mixed into the source shapes that carry one. + * mixed into the source types that carry one. * - * Discriminated by `form` so a producer cannot declare a shape without the - * facts that shape is presented from: a `notice` must record its one-line + * Discriminated by `form` so a producer cannot select a form without the + * fields needed to present it: a `notice` must record its one-line * account, a `snapshot` its sections. Omitting `form` stays valid — an * undeclared context is the documented default. */ @@ -188,13 +188,13 @@ type StreamChunk = 每个抛出的失败或最终适配器的带内失败都会规范化为一种可序列化、提供方无关的 payload。`providerRetryAfterMs` 是经校验、由提供方请求的正数延迟,而不是重试决策;`ProviderRequestId` 是用于诊断的不透明品牌字符串。 ```ts type-equiv -/** Serializable provider-boundary facts; policy decides whether they are retryable. */ +/** Serializable provider or transport failure facts; policy decides whether they are retryable. */ interface LlmFailure { /** Human-readable provider or transport failure. */ readonly message: string /** Stable provider-neutral machine-routing code. */ readonly code: string - /** HTTP status observed at the provider boundary, when available. */ + /** HTTP status returned by the provider, when available. */ readonly status?: number /** Provider-requested delay in milliseconds, when valid and available. */ readonly providerRetryAfterMs?: number @@ -209,7 +209,7 @@ interface LlmFailure { - **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。 - **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化为字符串。 -- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实以及实际服务注册所对应的不可变重试策略关联到该调用;agent loop(智能体循环)关闭失败步骤,再把错误、事实、不可变的先前已重试失败事实、实际服务策略和轮次信号提供给 `agent/request-error`。处理该错误的 listener 在其 await 的修复完成后返回 `{ kind: 'retry' }`;若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 +- **两条受支持的错误路径,共用一个 `LlmFailure` 类型。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。调用选定适配器后,流会保留被抛出的确切 `Error` 对象,并将不可变事实以及实际服务注册所对应的不可变重试策略关联到该调用;agent loop(智能体循环)关闭失败步骤,再把错误、事实、不可变的先前已重试失败事实、实际服务策略和轮次信号提供给 `agent/request-error`。处理该错误的 listener 在其 await 的修复完成后返回 `{ kind: 'retry' }`;若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 - **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的轮次;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 - **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。 - **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 @@ -219,7 +219,7 @@ interface LlmFailure { ## `ResolvedRetryPolicy` -提供方配置会在路由注册前解析为不可变的可辨识联合。normal mode 携带 `mode: 'normal'`、有限的 `maxRetries`、`retryableCodes`,以及必填的 `initialDelayMs`、`maxDelayMs` 与 `jitterRatio`;always mode 携带 `mode: 'always'` 和相同的必填退避字段,但没有有限上限。`LlmService.providerRetryPolicy(provider)` 返回当前注册的值,并在适配器省略策略时提供 normal 默认值;调用进入最终适配器边界后,`llmRetryPolicyOf(stream)` 返回为其提供服务的确切注册所捕获的值,因此之后释放或替换路由都无法改变进行中失败的恢复策略。可选输入形状由[生成的配置目录](../config-catalog.md)规定。 +提供方配置会在路由注册前解析为不可变的可辨识联合。normal mode 携带 `mode: 'normal'`、有限的 `maxRetries`、`retryableCodes`,以及必填的 `initialDelayMs`、`maxDelayMs` 与 `jitterRatio`;always mode 携带 `mode: 'always'` 和相同的必填退避字段,但没有有限上限。`LlmService.providerRetryPolicy(provider)` 返回当前注册的值,并在适配器省略策略时提供 normal 默认值;调用选定该注册后,`llmRetryPolicyOf(stream)` 返回为该调用服务的注册所捕获的值,因此之后释放或替换路由都无法改变进行中失败的恢复策略。可选配置输入字段由[生成的配置目录](../config-catalog.md)列出。 ## `AppIdentity`:应用归属 @@ -541,7 +541,7 @@ interface ToolSchema { } ``` -面向模型的 `ToolSchema` 是协议格式;产出它的已注册 `ToolDefinition`(schema + `execute`)在 [tools.md](tools.md) 中。 +面向模型的 `ToolSchema` 是协议类型;产出它的已注册 `ToolDefinition`(schema + `execute`)在 [tools.md](tools.md) 中。 界面正在起草的提供方既没有路由也没有 catalog,因此询问被单独描述:请求携带用户正在编辑的草稿,回复是界面可以采纳的候选,而不是它必须服务的 catalog。 @@ -598,7 +598,7 @@ interface LlmDiscoveredModel { `agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置以及由适配器默认值提供的字段。准备完成的调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 -在协议格式上,循环构建的请求先读取 `system` 槽位(渲染后的提示词组装),再读取派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。开发不变式针对每个循环构建的请求精确重算此等式。 +在协议中,循环构建的请求先读取 `system` 槽位(渲染后的提示词组装),再读取派生历史。已记录的请求快照会以最新的 `user/message`(轮次首步)或上一步的工具结果(后续步骤)结尾。开发不变式针对每个循环构建的请求精确重算此等式。 FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。 @@ -660,8 +660,8 @@ interface PreparedLlmCall { /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch - * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove the headers are added in the wire request or library header hook. The direct-fetch + * DeepSeek and library-backed pi-ai adapters meet this contract through different internals. */ declare abstract class LlmAdapter { /** diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 9e0a373532..65925a1608 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: 8f6872b77be8c7ae273e0fc1887dca30dbe1eb37 -persistence.zh.md: 25e72a69bd03cd5cc0715ae769055062466397dd +persistence.md: 0266d17393d07c258036f7054a02c4ab9d3c74a2 +persistence.zh.md: ced83440160ae91ae37025d8024068fb8148b0c6 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 8f6872b77b..0266d17393 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -211,7 +211,7 @@ interface SessionPersistenceSnapshot { Both implement the same abstract `SessionPersistence` (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass the shared `runPersistenceContract` suite: - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path. -- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. +- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row fields `(session_id, seq, type, time, data, source_event_seqs, surface_op)` map 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 25e72a69bd..ced8344016 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -211,7 +211,7 @@ interface SessionPersistenceSnapshot { 两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots,观察方法可选支持取消),并通过共享的 `runPersistenceContract` 套件: - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)**——每个会话一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。 -- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行结构 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 +- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行字段 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> diff --git a/docs/subsystems/plan.i18n.yaml b/docs/subsystems/plan.i18n.yaml index 2286bad3fd..ccf85341d1 100644 --- a/docs/subsystems/plan.i18n.yaml +++ b/docs/subsystems/plan.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/plan.md -plan.md: 29a2473c440b654422c4c563820efa72847a820a -plan.zh.md: bcb327719102b90c1c7009d78cee0fa930cdc7a8 +plan.md: 749f1052119f25e34dcfa8e6939a97037dc3b6dc +plan.zh.md: 31a402651f5c15b9c0cc264a7a51a020b4e63d60 diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md index 29a2473c44..749f105211 100644 --- a/docs/subsystems/plan.md +++ b/docs/subsystems/plan.md @@ -2,7 +2,7 @@ English | [中文](plan.zh.md) -Plan mode is logged per-agent collaboration state owned by [dsh-plan-mode](../../packages/plan/plan-mode) (`ctx.planMode`, `PlanModeService`): while active, a deployment-owned guidance section shapes each model request. It is **soft guidance**, deliberately independent of the [sandbox mode](sandbox.md) and [approval policy](approval.md) enforcement axes — those knobs never read or write plan state, and deployments needing a hard boundary combine them separately. The package is one optional capability, not part of the agent-loop spine; its surfaces are the `plan:policy` prompt section, the always-registered `exit_plan_mode` tool, and the `/plan` command. The [design note](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md) owns the rationale; the [package README](../../packages/plan/plan-mode/README.md) owns the model-experience and limitation detail. +Plan mode is logged per-agent collaboration state owned by [dsh-plan-mode](../../packages/plan/plan-mode) (`ctx.planMode`, `PlanModeService`): while active, a deployment-owned guidance section is included in each model request. Plan mode is **soft guidance**. [Sandbox mode](sandbox.md) and [approval policy](approval.md) enforce restrictions independently; neither reads or writes plan state, so deployments configure them separately. The package is optional, and the agent loop does not depend on it. It contributes the `plan:policy` prompt section and registers the `exit_plan_mode` tool and `/plan` command. The [design note](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md) owns the rationale; the [package README](../../packages/plan/plan-mode/README.md) owns the model-experience and limitation detail. Source: [`packages/plan/plan-mode/src/index.ts`](../../packages/plan/plan-mode/src/index.ts) @@ -10,11 +10,11 @@ Source: [`packages/plan/plan-mode/src/index.ts`](../../packages/plan/plan-mode/s `plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace [session event](session.md): durable and replayable, never in the model transcript. `foldPlanMode(events, end?)` returns the last logged value in the prefix, or `false` when there is none — the state in force is always a pure fold of the session log, so resume, fork, and compaction recover it with no live mirror, and UIs observe committed flips through `session/event`. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md). -## Pending intent and the step-boundary flush +## Pending selections and the pre-step append -Because every session event is turn-enclosed, a user selection is held as pending intent until the next step boundary — the next request derivation, in whichever turn it occurs (selection never forces continuation, so an intent recorded after a turn's final step lands in a later turn). `set(agent, active)` records the pending selection (a no-op when the target equals the logged-or-already-pending state), and `get(agent)` returns `{ active: boolean; pending?: boolean }` — the logged state shaping the current step, plus the optimistic selection awaiting a boundary. +Because every session event is turn-enclosed, a user selection remains pending until the next accepted in-turn pre-step appends it before request derivation, in whichever turn that occurs. A selection never forces continuation, so one made after a turn's final accepted pre-step is appended in a later turn. `set(agent, active)` records the pending selection (a no-op when the target equals the logged-or-already-pending state), and `get(agent)` returns `{ active: boolean; pending?: boolean }`: the logged state used to assemble the current step plus the selected state waiting to be appended. -The sole flush point is a prepended `agent/step` listener — the loop's in-turn interception point that runs before every request derivation, including turn 1 step 1 and request-recovery retries. Prompt admission itself never flushes: it happens pre-turn, where a `plan/mode` append would land outside any open turn, so a selection made at the prompt is landed by the first step boundary inside the turn it starts. The prepend means the flush runs before the downstream `agent/step` listener chain. A flush failure is contained — plan policy can never block a turn — and the failed append stays pending for a later boundary. A flushed user selection also narrates the switch as one plugin-sourced `user/message` notice, but only when the last logged request header described the other state, so the model is told exactly when its context changed and never redundantly. A pending selection made while idle is process-local and lost on exit before the next boundary ([README limitation](../../packages/plan/plan-mode/README.md#known-limitations-and-deferred-work)). +The only append point while an agent is running is a prepended `agent/pre-step` listener. It observes every proposed request step, including turn 1 step 1 and request-recovery retries, calls downstream listeners first, and appends only after they accept the step. Prompt admission happens before a turn and cannot append `plan/mode`, so a selection made at the prompt is appended by the first accepted in-turn pre-step of the turn it starts. An append failure cannot block the turn, and the selection remains pending for a later accepted in-turn pre-step. An appended user selection also records one plugin-sourced `user/message` notice, but only when the last logged request header described the other state, so the model is told exactly when its context changed and never redundantly. A selection made after a turn's final accepted pre-step remains process-local and is lost if the process exits before another accepted in-turn pre-step ([README limitation](../../packages/plan/plan-mode/README.md#known-limitations-and-deferred-work)). ## Configuration @@ -26,17 +26,17 @@ interface PlanModeConfig { } ``` -A missing, blank, or non-string `section` and any unknown key fail at plugin load rather than silently shaping nothing. While plan mode is active, the exact `section` text renders as the `plan:policy` [system-prompt section](system-prompt.md) at order 50; inactive plan mode contributes no text. +A missing, blank, or non-string `section` and any unknown key fail at plugin load rather than being ignored. While plan mode is active, the exact `section` text renders as the `plan:policy` [system-prompt section](system-prompt.md) at order 50; inactive plan mode contributes no text. ## The exit tool and the `/plan` command -[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) stays registered while plan mode is inactive, so crossing the boundary changes only the prompt section, never the request tool catalog; execution outside plan mode fails. In plan mode it requires a complete markdown plan starting with a `#` heading and presents it for review through the [user-interaction seam](user-interaction.md). Approval returns `{ approved: true }` and records a silent (non-narrated) pending exit that flushes after the step — plan guidance holds for the rest of the assistant's tool batch, and the tool result itself narrates the transition. Keep-planning is a failed call carrying the user's feedback, so the model revises and presents again; a missing interaction channel and a service reload during review also fail the call rather than silently leaving plan mode. +[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) stays registered while plan mode is inactive, so entering or leaving plan mode changes only the prompt section, never the request tool catalog; execution outside plan mode fails. In plan mode it requires a complete markdown plan starting with a `#` heading and presents it for review through the [user-interaction seam](user-interaction.md). Approval returns `{ approved: true }` and records a silent (non-narrated) pending exit that is appended at the next accepted in-turn pre-step. Plan guidance therefore remains active for the rest of the assistant's current tool batch, and the tool result itself reports the transition. Keep-planning is a failed call carrying the user's feedback, so the model revises and presents again; a missing interaction channel and a service reload during review also fail the call rather than silently leaving plan mode. -When [`ctx.commands`](commands.md) is composed, the plugin registers `/plan [off|message]`: bare `/plan` selects plan mode, any other non-empty message selects it and then submits the text through `agent.steer()` so it becomes the next step's ordinary logged user message under plan guidance, and the exact argument `off` selects inactive — which also cancels a not-yet-flushed pending entry before plan mode ever reaches a request. +When [`ctx.commands`](commands.md) is composed, the plugin registers `/plan [off|message]`: bare `/plan` selects plan mode, any other non-empty message selects it and then submits the text through `agent.steer()` so it becomes the next step's ordinary logged user message under plan guidance, and the exact argument `off` selects inactive, which also cancels a pending entry before it is appended and becomes visible to a request. ## The service -`ctx.planMode` owns the logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool; `get`/`set` signatures are in the generated [service catalog](#ctxplanmode--planmodeservice). +`ctx.planMode` owns the logged plan state, applies and narrates selected state at step start, and owns the `plan:policy` section, the `/plan` command, and the stable exit tool; `get`/`set` signatures are in the generated [service catalog](#ctxplanmode--planmodeservice). <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> @@ -50,11 +50,12 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeService` -`ctx.planMode`: owns logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. ```ts cordis-catalog /** - * Read the logged plan state and any selected state awaiting a boundary. + * Read the logged plan state and any selected state awaiting the next + * accepted in-turn pre-step. * * @param agent The agent to read. * @returns Current logged state plus a pending selection, when present. @@ -62,25 +63,25 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp get(agent: Agent): { active: boolean; pending?: boolean } /** - * Select whether plan mode should be active. Between turns the change - * commits immediately — no request boundary would arrive until the next - * prompt, so a queued intent would hang (the open-turn fold is the idle - * signal: agent status stays `running` through post-turn checkpointing, - * where a boundary equally never comes). During an open turn the - * selection is held as pending intent for the next in-turn request - * boundary. Repeated selection of the current or already-pending state is - * a no-op. + * Select whether plan mode should be active. Between turns the method + * appends the change immediately because no in-turn pre-step will run until + * another prompt starts a turn. The open-turn fold is the idle signal: + * agent status stays `running` through post-turn checkpointing, when no + * further in-turn pre-step runs. During an open turn the selection remains + * pending until the next accepted in-turn pre-step. Repeated selection of + * the current or already-pending state is a no-op. * * @param agent The agent to switch. * @param active Whether plan mode should be active. * @returns what happened: `committed` (logged now), `queued` (awaiting the - * next boundary), `cancelled` (an opposite pending selection was cleared; - * the logged state already matches), or `noop` (already in that state). + * next accepted in-turn pre-step), `cancelled` (an opposite pending selection + * was cleared; the logged state already matches), or `noop` (already in that + * state). */ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' ``` Types: [Agent](core.md) -Source: [`packages/plan/plan-mode/src/index.ts:183`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:184`](../../packages/plan/plan-mode/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md index bcb3277191..31a402651f 100644 --- a/docs/subsystems/plan.zh.md +++ b/docs/subsystems/plan.zh.md @@ -2,7 +2,7 @@ [English](plan.md) | 中文 -计划模式是 [dsh-plan-mode](../../packages/plan/plan-mode) 拥有的、记录到日志的逐 agent(智能体)协作状态(`ctx.planMode`,`PlanModeService`):激活期间,一段部署持有的指引段落会影响每个模型请求。它是**软性指引**,有意独立于[沙箱模式](sandbox.md)与[审批策略](approval.md)这两条强制执行轴:那些旋钮(knob)从不读写计划状态,需要硬边界的部署另行组合两者。该包(package)是一项可选能力,不属于 agent loop(智能体循环)主干;它的对外表面是 `plan:policy` 提示词段落、始终保持注册的 `exit_plan_mode` 工具和 `/plan` 命令。[设计说明](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)负责决策依据;[包 README](../../packages/plan/plan-mode/README.md) 负责模型体验与限制细节。 +计划模式是 [dsh-plan-mode](../../packages/plan/plan-mode) 拥有的、记录到日志的逐 agent(智能体)协作状态(`ctx.planMode`,`PlanModeService`):激活期间,每个模型请求都会包含一段部署持有的指引。计划模式是**软性指引**。[沙箱模式](sandbox.md)与[审批策略](approval.md)分别强制限制;两者都不读写计划状态,因此部署需要分别配置它们。该包(package)是可选项,agent loop(智能体循环)不依赖它。它贡献 `plan:policy` 提示词段落,并注册 `exit_plan_mode` 工具和 `/plan` 命令。[设计说明](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)负责决策依据;[包 README](../../packages/plan/plan-mode/README.md)负责模型体验与限制细节。 源码:[`packages/plan/plan-mode/src/index.ts`](../../packages/plan/plan-mode/src/index.ts) @@ -10,11 +10,11 @@ `plan/mode`(`{ active: boolean }`)是仅记日志、整值替换的[会话事件](session.md):持久且可回放,绝不进入模型 transcript(文本记录)。`foldPlanMode(events, end?)` 返回前缀中最后一条已记录值,没有时返回 `false`:生效状态始终是会话日志的纯折叠,因此恢复、fork 与压缩(compaction)无需实时镜像即可将其复原,UI 通过 `session/event` 观察已提交的切换。完整事件声明见[持久化日志事件目录](../persistence-catalog.md)。 -## 待定意图与步骤边界冲刷 +## 待生效选择与 pre-step 追加 -由于每个会话事件都位于轮次之内,用户的选择会作为待定意图保留到下一个步骤边界——即下一次请求派生,落在哪个轮次就在哪个轮次生效(选择绝不强制续行,因此在某轮最后一步之后记录的意图会在之后的轮次落地)。`set(agent, active)` 记录待定选择(目标值与已记录或已在待定中的状态相同时不做任何事),`get(agent)` 返回 `{ active: boolean; pending?: boolean }`,即影响当前步骤的已记录状态,加上正在等待边界的乐观选择。 +由于每个会话事件都位于轮次之内,用户选择会保持待生效状态,直到下一个被接受的轮内 pre-step 在派生请求之前追加该选择,无论该 pre-step 位于哪个轮次。选择不会强制续行,因此在某轮最后一个被接受的 pre-step 之后作出的选择会在之后的轮次追加。`set(agent, active)` 记录待生效选择(目标值与已记录或已在等待的状态相同时不做任何事),`get(agent)` 返回 `{ active: boolean; pending?: boolean }`:用于组装当前步骤的已记录状态,以及等待追加的已选状态。 -唯一的冲刷点是一个前置(prepend)注册的 `agent/step` 监听器——agent loop 的轮内拦截点,在每次请求派生之前运行,包括第 1 轮第 1 步和请求恢复重试。提示词提交本身绝不冲刷:它发生在轮次开启之前,此时追加 `plan/mode` 会落在任何开启的轮次之外,因此在提示词处做出的选择由它开启的轮次内的第一个步骤边界落地。前置注册意味着冲刷先于下游的 `agent/step` 监听器链运行。冲刷失败会被收容(计划策略绝不能阻塞轮次),追加失败的选择保持待定,等待后续边界。已冲刷的用户选择还会以一条插件来源的 `user/message` 通知叙述这次切换,但仅当最后记录的请求头描述的是另一种状态时才叙述,因此模型恰好在上下文变化时被告知,且绝不重复。空闲时做出的待定选择只存在于进程内,进程在下一个边界之前退出即丢失([README 限制](../../packages/plan/plan-mode/README.md#known-limitations-and-deferred-work))。 +agent 运行时,唯一的追加点是前置(prepend)注册的 `agent/pre-step` 监听器。它会观察每个候选请求步骤,包括第 1 轮第 1 步和请求恢复重试;它先调用下游监听器,只在下游接受该步骤后追加。提示词提交发生在轮次开启之前,无法追加 `plan/mode`,因此在提示词处作出的选择由它开启的轮次内第一个被接受的 pre-step 追加。追加失败不能阻塞轮次,且该选择会继续等待之后被接受的轮内 pre-step。追加用户选择时还会记录一条插件来源的 `user/message` 通知,但仅当最后记录的请求头描述的是另一种状态时才记录,因此模型恰好在上下文变化时收到通知,且绝不重复。在某轮最后一个被接受的 pre-step 之后作出的选择只存在于进程内;如果进程在另一个被接受的轮内 pre-step 之前退出,该选择会丢失([README 限制](../../packages/plan/plan-mode/README.md#known-limitations-and-deferred-work))。 ## 配置 @@ -26,17 +26,17 @@ interface PlanModeConfig { } ``` -`section` 缺失、为空白或不是字符串,以及任何未知键,都会在插件加载时失败,而不是静默地不产生任何指引。计划模式激活期间,确切的 `section` 文本以 order 50 渲染为 `plan:policy` [系统提示词段落](system-prompt.md);未激活的计划模式不贡献任何文本。 +`section` 缺失、为空白或不是字符串,以及任何未知键,都会在插件加载时失败,而不是被忽略。计划模式激活期间,确切的 `section` 文本以 order 50 渲染为 `plan:policy` [系统提示词段落](system-prompt.md);未激活的计划模式不贡献任何文本。 ## 退出工具与 `/plan` 命令 -[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) 在计划模式未激活时仍保持注册,因此跨越边界只改变提示词段落,绝不改变请求的工具目录;在计划模式之外执行会失败。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-interaction.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待定退出,在该步骤之后冲刷:计划指引在 assistant 本批工具调用的剩余部分继续生效,而工具结果本身叙述这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交;评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。 +[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) 在计划模式未激活时仍保持注册,因此进入或离开计划模式只改变提示词段落,绝不改变请求的工具目录;在计划模式之外执行会失败。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-interaction.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待定退出,由下一个被接受的轮内 pre-step 追加。因此,计划指引在 assistant 当前这批工具调用的剩余部分继续生效,而工具结果本身会报告这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交;评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。 -当 [`ctx.commands`](commands.md) 被组合时,插件注册 `/plan [off|message]`:单独的 `/plan` 选择计划模式;任何其他非空消息先选择计划模式,再通过 `agent.steer()` 提交该文本,使其在计划指引下成为下一步骤的普通已记录用户消息;确切参数 `off` 选择未激活,这还会在计划模式尚未进入任何请求之前,取消尚未冲刷的待定条目。 +当 [`ctx.commands`](commands.md) 被组合时,插件注册 `/plan [off|message]`:单独的 `/plan` 选择计划模式;任何其他非空消息先选择计划模式,再通过 `agent.steer()` 提交该文本,使其在计划指引下成为下一步骤的普通已记录用户消息;确切参数 `off` 选择未激活,这还会在待生效条目被追加并对请求可见之前将其取消。 ## 服务 -`ctx.planMode` 拥有已记录的计划状态、边界处的应用与叙述、`plan:policy` 段落、`/plan` 命令和稳定注册的退出工具;`get`/`set` 签名见生成的[服务目录](#ctxplanmode--planmodeservice)。 +`ctx.planMode` 拥有已记录的计划状态,在步骤开始时应用并叙述选中的状态,还拥有 `plan:policy` 段落、`/plan` 命令和稳定注册的退出工具;`get`/`set` 签名见生成的[服务目录](#ctxplanmode--planmodeservice)。 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> @@ -50,11 +50,12 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeService` -`ctx.planMode`: owns logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. ```ts cordis-catalog /** - * Read the logged plan state and any selected state awaiting a boundary. + * Read the logged plan state and any selected state awaiting the next + * accepted in-turn pre-step. * * @param agent The agent to read. * @returns Current logged state plus a pending selection, when present. @@ -62,25 +63,25 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp get(agent: Agent): { active: boolean; pending?: boolean } /** - * Select whether plan mode should be active. Between turns the change - * commits immediately — no request boundary would arrive until the next - * prompt, so a queued intent would hang (the open-turn fold is the idle - * signal: agent status stays `running` through post-turn checkpointing, - * where a boundary equally never comes). During an open turn the - * selection is held as pending intent for the next in-turn request - * boundary. Repeated selection of the current or already-pending state is - * a no-op. + * Select whether plan mode should be active. Between turns the method + * appends the change immediately because no in-turn pre-step will run until + * another prompt starts a turn. The open-turn fold is the idle signal: + * agent status stays `running` through post-turn checkpointing, when no + * further in-turn pre-step runs. During an open turn the selection remains + * pending until the next accepted in-turn pre-step. Repeated selection of + * the current or already-pending state is a no-op. * * @param agent The agent to switch. * @param active Whether plan mode should be active. * @returns what happened: `committed` (logged now), `queued` (awaiting the - * next boundary), `cancelled` (an opposite pending selection was cleared; - * the logged state already matches), or `noop` (already in that state). + * next accepted in-turn pre-step), `cancelled` (an opposite pending selection + * was cleared; the logged state already matches), or `noop` (already in that + * state). */ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' ``` Types: [Agent](core.md) -Source: [`packages/plan/plan-mode/src/index.ts:183`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:184`](../../packages/plan/plan-mode/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index e3e42cdc9e..bd07ed71fb 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md -session-projection.md: 4cbe0babb22406f7a48f0c19e982bb4757b4f44d -session-projection.zh.md: 5eada67a6eed914021e284fc5eabf203125b4b83 +session-projection.md: 6fdcfc64a5265c36f4396d91ee689c5f1cd1da18 +session-projection.zh.md: 3ca65ba40be32ed11e319c05410c035683f46488 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 4cbe0babb2..6fdcfc64a5 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -45,7 +45,7 @@ interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> { */ view(state: S): SessionProjectionMap[K] /** - * Persisted-cache invalidation anchor: bump whenever the state shape or the + * Persisted-cache invalidation version: bump whenever the serialized state fields or the * fold semantics change, so persisted `(sessionId, key, ver, seq, val)` * rows from an older unit are discarded instead of being forward-applied * into garbage. Non-negative integer. @@ -86,7 +86,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` is fully synchronous — a carrier reads it in the same tick as its page slice, which is what makes `asOfSeq` one consistent cut — and every value passes its unit's schema before leaving (an accidentally-async `view` returns a Promise, which fails that boundary parse loudly). The change feed fires once per unit whose state *reference* changed, per committed event: the same-reference discipline in `apply` is the gate. +`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. Every value passes its unit's schema before return; an accidentally async `view` returns a Promise, which schema validation rejects. The change feed fires once per unit whose state *reference* changed for each committed event; `apply` must return the same reference when its state did not change. ## The registry: `ctx.sessionProjections` @@ -162,7 +162,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack * context's fiber: disposing the fiber (or calling the returned disposer) * removes the key — and the unit's cached cells — from subsequent drives * and snapshots. - * @param definition - key, boundary schema, pure unit functions, and stateVersion. + * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ register<K extends keyof SessionProjectionMap, S>(definition: ProjectionDefinition<K, S>): () => void diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 5eada67a6e..3ca65ba40b 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -45,7 +45,7 @@ interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> { */ view(state: S): SessionProjectionMap[K] /** - * Persisted-cache invalidation anchor: bump whenever the state shape or the + * Persisted-cache invalidation version: bump whenever the serialized state fields or the * fold semantics change, so persisted `(sessionId, key, ver, seq, val)` * rows from an older unit are discarded instead of being forward-applied * into garbage. Non-negative integer. @@ -86,7 +86,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` 是完全同步的:载体在切出页面切片的同一 tick 内读取它,`asOfSeq` 之所以是一个一致切面正系于此;且每个值在离开前都要经过其单元的 schema 校验(误写成异步的 `view` 会返回 Promise,让这道边界解析当场大声失败)。变更流对每个已提交事件、每个状态*引用*发生变化的单元各触发一次:`apply` 的同引用纪律就是那道闸门。 +`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。每个值在返回前都会通过其单元的 schema 校验;如果 `view` 被误写为异步函数,它会返回 Promise,schema 校验将拒绝该值。对于每个已提交事件,变更流会为每个状态*引用*已变化的单元触发一次;状态未变时,`apply` 必须返回同一引用。 ## 注册表:`ctx.sessionProjections` @@ -162,7 +162,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack * context's fiber: disposing the fiber (or calling the returned disposer) * removes the key — and the unit's cached cells — from subsequent drives * and snapshots. - * @param definition - key, boundary schema, pure unit functions, and stateVersion. + * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ register<K extends keyof SessionProjectionMap, S>(definition: ProjectionDefinition<K, S>): () => void diff --git a/docs/subsystems/session-query.i18n.yaml b/docs/subsystems/session-query.i18n.yaml index 4ba7a3392b..e69b3ef46f 100644 --- a/docs/subsystems/session-query.i18n.yaml +++ b/docs/subsystems/session-query.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-query.md -session-query.md: 3b6d99af58ffb7c3e7b58cc4a3e9143732382861 -session-query.zh.md: c23e9ad53ef6a3e4a8e50a892824068f33e4e15b +session-query.md: 7a414c89124dde9972396f77281f50bf1643d716 +session-query.zh.md: e8b98709c98bbd013730d4bf3c9807a418680d7d diff --git a/docs/subsystems/session-query.md b/docs/subsystems/session-query.md index 3b6d99af58..7a414c8912 100644 --- a/docs/subsystems/session-query.md +++ b/docs/subsystems/session-query.md @@ -456,7 +456,7 @@ async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFi /** * Read one session's complete current model surface from one corpus observation. * @param sessionId - live-preferred session id to read. - * @returns cloned header, current surface, and raw-log capture boundary. + * @returns cloned header, current surface, and the last sequence number included in the raw-log capture. * @throws when source resolution fails or the session surface is invalid. */ async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot> @@ -465,7 +465,7 @@ async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot> * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. * @param signal - optional cancellation for persistence listing. - * @returns a complete lineage or an explicit unresolved parent boundary. + * @returns a complete lineage or the first parent that could not be resolved. * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. */ async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace> diff --git a/docs/subsystems/session-query.zh.md b/docs/subsystems/session-query.zh.md index c23e9ad53e..e8b98709c9 100644 --- a/docs/subsystems/session-query.zh.md +++ b/docs/subsystems/session-query.zh.md @@ -456,7 +456,7 @@ async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFi /** * Read one session's complete current model surface from one corpus observation. * @param sessionId - live-preferred session id to read. - * @returns cloned header, current surface, and raw-log capture boundary. + * @returns cloned header, current surface, and the last sequence number included in the raw-log capture. * @throws when source resolution fails or the session surface is invalid. */ async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot> @@ -465,7 +465,7 @@ async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot> * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. * @param signal - optional cancellation for persistence listing. - * @returns a complete lineage or an explicit unresolved parent boundary. + * @returns a complete lineage or the first parent that could not be resolved. * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. */ async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace> diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index a73c858289..a7f5e0fe0b 100644 --- a/docs/subsystems/session-reference.i18n.yaml +++ b/docs/subsystems/session-reference.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-reference.md -session-reference.md: f539a59b8d26182aff9746b6d6a39a86ba15cb45 -session-reference.zh.md: 1035614141936af1f94bdabe35b3104be319a762 +session-reference.md: 29fd85c74c54b0542a241b02cd31fc6c93012ae8 +session-reference.zh.md: 2a551de5d7bd08ed70b56035b617b2d69c9d6244 diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index f539a59b8d..29fd85c74c 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -2,7 +2,7 @@ English | [中文](session-reference.zh.md) -Structured cross-session reference requests and prepared message contexts. The [package contract](../../packages/context/session-reference) owns canonical URIs, current-surface projection, tag-safe JSON and byte retention, stable errors, and the untrusted model prompt. Host adapters use these types instead of passing their UI mention syntax into the agent core. +Structured cross-session reference requests and prepared message contexts. The [package contract](../../packages/context/session-reference) defines canonical URIs, current-surface projection, tag-safe JSON and byte retention, stable errors, and the untrusted model prompt. Host adapters use these types instead of passing their UI mention syntax into the agent core. Source: [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 1035614141..2a551de5d7 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -2,7 +2,7 @@ [English](session-reference.md) | 中文 -结构化的跨会话引用请求与准备后的消息上下文。[包约定](../../packages/context/session-reference) 负责规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 +结构化的跨会话引用请求与准备后的消息上下文。[包约定](../../packages/context/session-reference) 定义规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 来源:[`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index e9d7a2f936..7ba2ae289d 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 6fb0cec4fd222ceafbd5b4111fe56f22505058ad -session.zh.md: d33a71e92e2bd9fd9fb7e9194255b7c1f5f0af77 +session.md: 0b78e51ebf6e2ad5c312268ad4bfb4392b0486df +session.zh.md: d1e91f684a835e08406f524efe876baa1a6a72cb diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 6fb0cec4fd..0b78e51ebf 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -345,7 +345,7 @@ interface SurfaceFoldResult { ## `Session` public API -The body-stripped declaration keeps the plain class's detached factory, state accessors, append boundary, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` section](#ctxsessions--sessionstore). +The body-stripped declaration keeps the plain class's detached factory, state accessors, append method, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` section](#ctxsessions--sessionstore). ```ts public-api /** @@ -404,8 +404,8 @@ declare class Session { static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session; /** * Restore a detached session by taking ownership of fresh persistence values. - * Storage shape, event envelopes, sequence continuity, surface transitions, - * and header fields are validated before the graphs are frozen in place. + * The storage format, event envelopes, sequence continuity, surface transitions, + * and header fields are validated before the restored objects are frozen. * @param id - restored session identity. * @param seed - fresh detached events whose ownership is transferred. * @param header - fresh detached metadata whose ownership is transferred. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d33a71e92e..d1e91f684a 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -177,7 +177,7 @@ interface EpochHeader { ### 路由容量事件:`request/context` -请求所解析到的路由的上下文元数据是独立的已记录状态,在同一步骤内紧随 `request/header` 追加,且仅在提供方、模型或容量与上一条记录不同时追加。它保持在 `EpochHeader` 之外,因为该类型是由 `headerEquals` 逐字段比较的重建约定:容量描述的是路由,不是请求输入,把它折叠进去会让一次容量变化被登记为请求信封的 `change`,也会把适配器元数据拉进 loop 的重建不变式。与 `request/header` 一样,它不是 `SurfaceEventType`,也不产生 LLM 消息。`session.requestContext()` 以增量方式归并最新一条记录。适配器不公布容量的路由会以缺失 `contextWindow` 的形式记录,因此新记录可以清除较早路由的容量。 +请求所解析到的路由的上下文元数据是独立的已记录状态,在同一步骤内紧随 `request/header` 追加,且仅在提供方、模型或容量与上一条记录不同时追加。它保持在 `EpochHeader` 之外,因为该类型是 `headerEquals` 逐字段比较的重建约定。容量描述的是路由,不是请求输入,把它折叠进去会让一次容量变化被登记为请求信封的 `change`,也会把适配器元数据拉进 loop 的重建不变式。与 `request/header` 一样,它不是 `SurfaceEventType`,也不产生 LLM 消息。`session.requestContext()` 以增量方式归并最新一条记录。适配器不公布容量的路由会以缺失 `contextWindow` 的形式记录,因此新记录可以清除较早路由的容量。 ```ts type-equiv /** Registration-bound metadata for one resolved model route. */ @@ -347,7 +347,7 @@ interface SurfaceFoldResult { ## `Session` 公共 API -去除方法体的声明与源码中的普通类保持同步,覆盖其脱离态工厂、状态访问器、追加边界和历史投影。存储操作仍由生成的 [`ctx.sessions` 小节](#ctxsessions--sessionstore)记录。 +去除方法体的声明与源码中的普通类保持同步,覆盖其脱离态工厂、状态访问器、append 方法和历史投影。存储操作仍由生成的 [`ctx.sessions` 小节](#ctxsessions--sessionstore)记录。 ```ts public-api /** @@ -406,8 +406,8 @@ declare class Session { static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session; /** * Restore a detached session by taking ownership of fresh persistence values. - * Storage shape, event envelopes, sequence continuity, surface transitions, - * and header fields are validated before the graphs are frozen in place. + * The storage format, event envelopes, sequence continuity, surface transitions, + * and header fields are validated before the restored objects are frozen. * @param id - restored session identity. * @param seed - fresh detached events whose ownership is transferred. * @param header - fresh detached metadata whose ownership is transferred. diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index a257e0e33e..3fa99a4ecf 100644 --- a/docs/subsystems/settings.i18n.yaml +++ b/docs/subsystems/settings.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/settings.md -settings.md: 9256bf9436d2e77093fc8c6a3728b62fc4e8d67f -settings.zh.md: 720eb9c2718c1fa14a148cced806c224634dba69 +settings.md: fc10f72c0ed59a982817eb65ecc410e039a2cbb3 +settings.zh.md: 96bb2d8b0cd65e1ebd812e000fe3b96e8b315493 diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index 9256bf9436..fc10f72c0e 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -8,7 +8,7 @@ Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/sett ## Identity -A namespace names one plugin-owned section of the user document. The brand keeps namespaces from mixing with other cross-boundary ids; construction validates the lowercase kebab-case shape. +A namespace names one plugin-owned section of the user document. The brand prevents callers from mixing settings namespaces with other ids passed between packages or processes; construction validates lowercase kebab-case syntax. ```ts type-equiv /** Nominal id of one registered settings namespace. */ @@ -79,14 +79,14 @@ interface SettingsScope<T> { watch(callback: (next: T, prev: T) => void | Promise<void>): () => void /** * Merge a partial patch into this namespace's user layer and persist it. - * @param patch - plain-object patch over the user section; JSON-shaped data + * @param patch - plain-object patch over the user section; JSON-compatible data * only (non-JSON values reject with their path before anything persists). */ update(patch: object): Promise<void> /** * Replace this namespace's user section wholesale; absent keys re-inherit * the composition `base` and schema defaults (`replace({})` resets all). - * @param section - the complete next user section; JSON-shaped data only, + * @param section - the complete next user section; JSON-compatible data only, * as for {@link update}. */ replace(section: object): Promise<void> diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index 720eb9c271..96bb2d8b0c 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -8,7 +8,7 @@ ## 标识 -namespace 命名用户文档中一个归插件所有的分节。brand 使其不与其他跨边界 id 混用;构造时校验小写 kebab-case 形态。 +namespace 命名用户文档中一个归插件所有的分节。brand 防止调用方将设置 namespace 与在包或进程之间传递的其他 id 混用;构造时校验小写 kebab-case 语法。 ```ts type-equiv /** Nominal id of one registered settings namespace. */ @@ -79,14 +79,14 @@ interface SettingsScope<T> { watch(callback: (next: T, prev: T) => void | Promise<void>): () => void /** * Merge a partial patch into this namespace's user layer and persist it. - * @param patch - plain-object patch over the user section; JSON-shaped data + * @param patch - plain-object patch over the user section; JSON-compatible data * only (non-JSON values reject with their path before anything persists). */ update(patch: object): Promise<void> /** * Replace this namespace's user section wholesale; absent keys re-inherit * the composition `base` and schema defaults (`replace({})` resets all). - * @param section - the complete next user section; JSON-shaped data only, + * @param section - the complete next user section; JSON-compatible data only, * as for {@link update}. */ replace(section: object): Promise<void> diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 9b3d8a19d6..1aaffa63cc 100644 --- a/docs/subsystems/storage.i18n.yaml +++ b/docs/subsystems/storage.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/storage.md -storage.md: 3234fdf9587dc2853b4f9ec4205d230aae4b633a -storage.zh.md: fb13ec96099f2173478aecf1b91799237755414c +storage.md: fd5a8d7eaeb545ef307434b211556633bba8b503 +storage.zh.md: 4ce5ebb2ad59315d2a60d57de087f3050c323722 diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 3234fdf958..fd5a8d7eae 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -29,10 +29,10 @@ interface StorageForms {} /** * One registered backend. A backend owns exactly one medium and shares its * lifecycle across all facets; facets are optional members — a backend that - * cannot serve a shape simply omits it, and resolution fails loud instead. + * cannot serve a data kind simply omits it, and resolution fails loud instead. */ interface StorageBackend { - /** Key-value data shape; absent when this backend cannot serve it. */ + /** Key-value operations; absent when this backend cannot serve them. */ readonly kv?: KvFacet /** @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -A backend owns one medium (a file-tree root, a database file) and exposes optional data-shape facets; `kv` is the sole facet. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, format version, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. A medium stamped with a different version rejects `version-mismatch`; one that cannot be parsed as the unit rejects `malformed-medium` (no migration, pre-release stance). [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) asserts every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores document-per-row in one database, the route for high-churn domains. +A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only group today. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, format version, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. A medium stamped with a different version rejects `version-mismatch`; one that cannot be parsed as the unit rejects `malformed-medium` (no migration, pre-release stance). [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. ## Declaring a domain diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index fb13ec9609..4ce5ebb2ad 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -29,10 +29,10 @@ interface StorageForms {} /** * One registered backend. A backend owns exactly one medium and shares its * lifecycle across all facets; facets are optional members — a backend that - * cannot serve a shape simply omits it, and resolution fails loud instead. + * cannot serve a data kind simply omits it, and resolution fails loud instead. */ interface StorageBackend { - /** Key-value data shape; absent when this backend cannot serve it. */ + /** Key-value operations; absent when this backend cannot serve them. */ readonly kv?: KvFacet /** @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并暴露可选的数据形状 facet;`kv` 是唯一的 facet。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、格式版本、表名清单,以及是否存在全局单例槽位——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。介质上记录的版本与之不同时拒绝 `version-mismatch`;无法按该 unit 解析的介质拒绝 `malformed-medium`(不做迁移:预发布立场)。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件对每个后端断言其中每一条款。[json 后端](../../packages/storage/storage-json/README.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.md)在单个数据库中按一行一文档存储,是高频更新领域的路由选择。 +一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;目前 `kv` 是唯一一组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、格式版本、表名清单,以及是否存在全局单例槽位——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。介质上记录的版本与之不同时拒绝 `version-mismatch`;无法按该 unit 解析的介质拒绝 `malformed-medium`(不做迁移:预发布立场)。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 ## 声明领域 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index e98438900d..ddbc9ec3db 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: 961a16f58cb936205290d23ce6b9d94cb94607d5 +subagent.zh.md: ac97d27d9824ca62701e11ae99264adbddafbbd8 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 99c54e0696..961a16f58c 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -2,7 +2,7 @@ English | [中文](subagent.zh.md) -The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. +The subagent seam lets an agent delegate work to a child agent. Like [bash](bash.md), it is **one optional capability**, not part of the agent loop, so its types live here rather than in [core.md](core.md). It differs from the other capability seams because **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), while bash allows only one executor. Its registry follows the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Service providers are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`, `-claude-code`, `-dsh-sdk`); the model-facing Consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`, `interrupt_agent`, and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only child and descendant discovery straight from the session store and optional session persistence. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). @@ -113,7 +113,7 @@ interface ResolvedSubagentStartRequest extends SubagentStartRequest { ## Continuable children and activations -A **continuable background subagent** is one durable child Session with at most one process-local **Activation** — a residency epoch for a reconstructed child Agent. An Activation is not a request, result, cancellation, or Task boundary: it may execute many FIFO turns and stays resident while descendants it created are still running. The continuation manager owns activation admission, direct-parent authorization, the live ownership graph, cold resume, and child-first disposal; the Agent loop owns all turn ordering and execution. No continuable path creates a Task or an intermediate result-bearing wrapper. +A **continuable background subagent** is one durable child Session with at most one process-local **Activation**, the period when a reconstructed child Agent is resident. An Activation is not a request, result, cancellation, or Task: it may execute many FIFO turns and stays resident while descendants it created are still running. The continuation manager owns activation admission, direct-parent authorization, the live ownership graph, cold resume, and child-first disposal; the Agent loop owns all turn ordering and execution. No continuable path creates a Task or an intermediate result-bearing wrapper. ```text persisted Session @@ -299,8 +299,9 @@ interface SubagentResult { * The structured result after a requested `outputSchema` was successfully * satisfied. Requesting a schema does not guarantee presence: a provider can * end with `stopReason: 'error'` when the child fails or finishes without a - * valid capture. Shape is validated against the request schema by the - * provider; `unknown` here because the seam is schema-agnostic. + * valid capture. The structured value is validated against the requested + * output schema by the provider; `unknown` here because the seam is + * schema-agnostic. */ readonly structured?: unknown /** Why the run ended. A non-`completed` reason means `output` may be partial. */ diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index c5a79247a8..ac97d27d98 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -2,7 +2,7 @@ [English](subagent.md) | 中文 -subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 +subagent seam 让一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环),因此其类型定义在此而非 [core.md](core.md) 中。它不同于其他能力 seam,因为**同一上下文中可共存多个提供方实现**,并按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。该注册表遵循 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。Service provider 是六个兄弟包:`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`、`-claude-code`、`-dsh-sdk`;面向模型的 Consumer 包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`、`interrupt_agent` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并直接基于会话存储和可选的会话持久化提供只读的 child 与后代发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 @@ -113,7 +113,7 @@ interface ResolvedSubagentStartRequest extends SubagentStartRequest { ## 可继续子 agent 与激活 -**可继续后台 subagent** 是一份持久化子 agent 会话(Session),至多关联一个进程内的 **Activation(激活)**——即被重建的子 Agent 的一段驻留纪元(residency epoch)。Activation 不是请求、结果、取消或 Task 边界:它可以执行多个 FIFO 轮次,并在其创建的后代仍在运行期间保持驻留。继续执行管理器负责 activation 准入、直接父级鉴权、实时所有权图、冷恢复(cold resume)与子级优先释放;agent loop 负责一切轮次排序与执行。任何可继续路径都不会创建 Task,也不会创建承载中间结果的包装层。 +**可继续后台 subagent** 是一份持久化子 agent 会话(Session),至多关联一个进程内的 **Activation(激活)**,即被重建的子 Agent 处于驻留状态的时段。Activation 不是请求、结果、取消或 Task:它可以执行多个 FIFO 轮次,并在其创建的后代仍在运行期间保持驻留。继续执行管理器负责 activation 准入、直接父级鉴权、实时所有权图、冷恢复(cold resume)与子级优先释放;agent loop 负责一切轮次排序与执行。任何可继续路径都不会创建 Task,也不会创建承载中间结果的包装层。 ```text persisted Session @@ -299,8 +299,9 @@ interface SubagentResult { * The structured result after a requested `outputSchema` was successfully * satisfied. Requesting a schema does not guarantee presence: a provider can * end with `stopReason: 'error'` when the child fails or finishes without a - * valid capture. Shape is validated against the request schema by the - * provider; `unknown` here because the seam is schema-agnostic. + * valid capture. The structured value is validated against the requested + * output schema by the provider; `unknown` here because the seam is + * schema-agnostic. */ readonly structured?: unknown /** Why the run ended. A non-`completed` reason means `output` may be partial. */ diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index 91ff1b49d6..c24ae31019 100644 --- a/docs/subsystems/system-prompt.i18n.yaml +++ b/docs/subsystems/system-prompt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/system-prompt.md -system-prompt.md: 5397858ea9991efad06e045118b96a90386f2285 -system-prompt.zh.md: defd8fae73834ba543ae1f45d15ca4253a5abe40 +system-prompt.md: bdc0e994fb8e784a19814574c405d8cc3dce2d11 +system-prompt.zh.md: db6932b18f4721020fed567d49727f863eb06608 diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index 5397858ea9..bdc0e994fb 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -2,7 +2,7 @@ English | [中文](system-prompt.zh.md) -The [system-prompt package](../../packages/core/system-prompt) owns the data exchanged between prompt contributors and one assembly call. The package [README](../../packages/core/system-prompt/README.md) documents registration, ordering, scoping, and rendering behavior; this page pins the literal cross-package shapes that plugins implement or pass. +The [system-prompt package](../../packages/core/system-prompt) owns the data exchanged between prompt contributors and one assembly call. The package [README](../../packages/core/system-prompt/README.md) documents registration, ordering, scoping, and rendering behavior; this page records the exact cross-package types that plugins implement or pass. Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts). diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index defd8fae73..db6932b18f 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -2,7 +2,7 @@ [English](system-prompt.md) | 中文 -[system-prompt 包](../../packages/core/system-prompt)负责管理提示词贡献者与一次组装调用之间交换的数据。该包的 [README](../../packages/core/system-prompt/README.md) 记录了注册、排序、作用域与渲染行为;本页固定各插件实现或传递的跨包字面形状。 +[system-prompt 包](../../packages/core/system-prompt)负责管理提示词贡献者与一次组装调用之间交换的数据。该包的 [README](../../packages/core/system-prompt/README.md) 记录注册、排序、作用域与渲染行为;本页记录各插件实现或传递的确切跨包类型。 源码:[`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts)。 diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index c229d0a5c0..5956c1fc4f 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: d3e92891a6736a85519b97775ebe7aa6f12a5ae2 -tasks.zh.md: fe54d7a6482743a8f2ef31b143edb25afe0a478b +tasks.md: de331045d6cbec64c1305b0a20f0c821e9578469 +tasks.zh.md: f33da2a7d0e09d094110c89f88b6f7508400da9b diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index d3e92891a6..de331045d6 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -2,7 +2,7 @@ English | [中文](tasks.zh.md) -Types shared by long-running producers, `ctx.tasks`, and task control surfaces. The [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the literal shapes from [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts). +Types shared by long-running producers, `ctx.tasks`, and task controls. The [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the exact fields and variants from [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts). ## Ids and status @@ -57,7 +57,7 @@ interface TaskStart { } ``` -`TaskHooks.done` is the quiescence boundary. Optional `readOutput` distinguishes consuming stream tasks from final-output-only tasks. +`TaskHooks.done` resolves after the producer releases its resources, not merely when work finishes. Optional `readOutput` distinguishes consuming stream tasks from final-output-only tasks. ```ts type-equiv /** Hooks through which the runtime controls and observes producer work. */ @@ -151,7 +151,7 @@ interface TaskRead { ## Service behavior -The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition specifies 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 provider. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the Service Definition 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 Consumer. +The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, failure-isolated `onTaskDone` listeners, and when `attachSurface` becomes available; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local Service provider. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the Service Definition 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 Consumer. <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index fe54d7a648..f33da2a7d0 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -2,7 +2,7 @@ [English](tasks.md) | 中文 -长时间运行的生产方、`ctx.tasks` 与任务控制接口共用的类型。[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)负责设计;本页记录 [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts) 中的字面形状。 +长时间运行的生产方、`ctx.tasks` 与任务控制命令共用的类型。[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)负责设计;本页记录 [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts) 中的确切字段和变体。 ## ID 与状态 @@ -57,7 +57,7 @@ interface TaskStart { } ``` -`TaskHooks.done` 是完全停稳边界。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。 +`TaskHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。 ```ts type-equiv /** Hooks through which the runtime controls and observes producer work. */ @@ -151,7 +151,7 @@ interface TaskRead { ## 服务行为 -抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 可用性防线;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部提供方。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 +抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 何时可用;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部 Service provider。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> diff --git a/docs/subsystems/telemetry.i18n.yaml b/docs/subsystems/telemetry.i18n.yaml index c994c46baa..f5cda71a1d 100644 --- a/docs/subsystems/telemetry.i18n.yaml +++ b/docs/subsystems/telemetry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/telemetry.md -telemetry.md: 93869f2344eeedfee344735191af842fef188886 -telemetry.zh.md: 50642f1ed917ea52ee152e333f70753c12fba583 +telemetry.md: 5ea5c67210ce1387cbd886935e914baf7f904fbb +telemetry.zh.md: bd8fc8acc4c8522d8b1e4bc543431c0abf224411 diff --git a/docs/subsystems/telemetry.md b/docs/subsystems/telemetry.md index 93869f2344..5ea5c67210 100644 --- a/docs/subsystems/telemetry.md +++ b/docs/subsystems/telemetry.md @@ -2,7 +2,7 @@ English | [中文](telemetry.zh.md) -Outbound session reporting is split as a [capability seam](../capability-seams.md): the Service Definition and capture coordinator ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) own the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, handoff cursor, and minimal backend contract; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) is the OpenTelemetry JS SDK's log pipeline configured verbatim. It is one optional capability, not part of the agent-loop spine, and nothing here reaches a model request. The boundary axiom — the harness's aspect ends at `emit()`; batching, retry, queueing, and loss policy belong to the reporting SDK — and the rejected alternatives are pinned in the [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md); the capture points, cursor, and projection contracts live in the [Service Definition README](../../packages/session/session-telemetry/README.md). +Outbound session reporting is one [capability seam](../capability-seams.md): its Service Definition ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) declares the minimal backend contract, and its capture coordinator owns the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, and handoff cursor; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) uses the OpenTelemetry JS SDK's log pipeline with its configuration unchanged. This optional capability is not part of the agent loop, and nothing here reaches a model request. The harness stops after it calls `emit()`; the reporting SDK owns batching, retry, queueing, and loss policy. The [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) records that rule and the rejected alternatives. The [Service Definition README](../../packages/session/session-telemetry/README.md) defines the capture-point, cursor, and projection contracts. Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -60,9 +60,8 @@ Only the first `assistant/chunk` of each `(turn, step)` ships — the stream-sta ```ts type-equiv /** - * The backend contract the coordinator hands records to — the minimum any - * reporting SDK satisfies with zero bending. {@link Telemetry} is its - * service-registered form; tests compose the coordinator with a bare + * The minimum backend contract the coordinator requires. {@link Telemetry} is + * its service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -77,8 +76,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a natural boundary (turn end) passed — a backend may - * forward it to its SDK's flush so records land at turn boundaries. Called + * Optional hint that a turn ended. A backend may forward it to its SDK's + * flush so records are exported after each turn. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -105,7 +104,7 @@ interface TelemetryBackend { } ``` -`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the contract's loadable form — one implementation per context, duplicate load throws — and a backend composes the seam's `TelemetryCoordinator` in its constructor to install the capture side. +`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the loadable form of this contract: each context accepts one implementation and throws on a duplicate. A backend constructs `TelemetryCoordinator` in its constructor to install capture. ## The redact waterfall: `telemetry/record` @@ -123,7 +122,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -142,7 +141,7 @@ flush?(): void abstract shutdown(): Promise<void> ``` -Source: [`packages/session/session-telemetry/src/index.ts:140`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts) <a id="telemetry-events"></a> diff --git a/docs/subsystems/telemetry.zh.md b/docs/subsystems/telemetry.zh.md index 50642f1ed9..bd8fc8acc4 100644 --- a/docs/subsystems/telemetry.zh.md +++ b/docs/subsystems/telemetry.zh.md @@ -2,7 +2,7 @@ [English](telemetry.md) | 中文 -对外的会话上报拆分为一项[能力 seam](../capability-seams.md):Service Definition 与捕获协调器([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.telemetry`)拥有捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)、handoff 游标与最小后端约定;部署方加载的 Service provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))则是原样配置的 OpenTelemetry JS SDK 日志流水线。它是一项可选能力,不属于 agent loop(智能体循环)主干,这里也没有任何内容会进入模型请求。边界公理(harness 的职责止于 `emit()`;批处理、重试、排队与丢失策略都属于上报 SDK)连同被否决的替代方案,均已在[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)中定案;捕获点、游标与投影的约定见 [Service Definition README](../../packages/session/session-telemetry/README.md)。 +对外会话上报是一项[能力 seam](../capability-seams.md):其 Service Definition([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.telemetry`)声明最小后端约定,其捕获协调器负责捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)和 handoff 游标;部署方加载的 Service provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))按原配置使用 OpenTelemetry JS SDK 日志流水线。这项能力可选,不属于 agent loop(智能体循环),这里也没有任何内容会进入模型请求。Harness 调用 `emit()` 后停止处理;上报 SDK 负责批处理、重试、排队和丢失策略。[复活 Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)记录了这条规则和被否决的替代方案。[Service Definition README](../../packages/session/session-telemetry/README.md) 定义捕获点、游标和投影约定。 源码:[`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -60,9 +60,8 @@ interface TelemetryRecord { ```ts type-equiv /** - * The backend contract the coordinator hands records to — the minimum any - * reporting SDK satisfies with zero bending. {@link Telemetry} is its - * service-registered form; tests compose the coordinator with a bare + * The minimum backend contract the coordinator requires. {@link Telemetry} is + * its service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -77,8 +76,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a natural boundary (turn end) passed — a backend may - * forward it to its SDK's flush so records land at turn boundaries. Called + * Optional hint that a turn ended. A backend may forward it to its SDK's + * flush so records are exported after each turn. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -105,7 +104,7 @@ interface TelemetryBackend { } ``` -`Telemetry`(`ctx.telemetry`,[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载形态:每个上下文只允许一个实现,重复加载会抛出异常;后端在其构造函数中组合 seam 的 `TelemetryCoordinator`,以此装配捕获侧。 +`Telemetry`(`ctx.telemetry`,[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载类型:每个上下文只允许一个实现,重复加载会抛出异常。后端在构造函数中创建 `TelemetryCoordinator`,以安装捕获处理。 ## 脱敏 waterfall:`telemetry/record` @@ -123,7 +122,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -142,7 +141,7 @@ flush?(): void abstract shutdown(): Promise<void> ``` -Source: [`packages/session/session-telemetry/src/index.ts:140`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts) <a id="telemetry-events"></a> diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index ed9d60978f..3f7e33a795 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tools.md -tools.md: 692bafa02e37e1c1918fda31c766ad32f7c7cdba -tools.zh.md: 81aabddd20e2a0d09d904f4f8521d65c2622351f +tools.md: f8d86704a2237219530c8c23b46a68383458e1cf +tools.zh.md: 87269e5532b0cdfb0a38501d7b98c9986665df1d diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 692bafa02e..f8d86704a2 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -2,7 +2,7 @@ English | [中文](tools.zh.md) -The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine; the model-facing [`ToolSchema`](llm-streaming.md#the-model-request-and-result) wire shape is declared with the model request. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the guarded execution shapes, and the UI-presentation vocabulary. +The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the pipeline-authoring type shared by the core packages; the model-facing [`ToolSchema`](llm-streaming.md#the-model-request-and-result) wire type is declared with the model request. This page documents every `ToolDefinition` field, the typed schema DSL that builds it, the guarded execution types, and the UI-presentation types. Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) · [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts) @@ -148,7 +148,7 @@ type InferArgs<S> = InferProperties<S, []> `defineTool({ name, description, parameters, output, execute, … })` ties parameter inference to `parameterSchemaSpecToJsonSchema()` and `validateArgs()`, and ties `execute`/`render`/`presentationMeta` to `InferValue<OutputSchema>`. Schema records contain only own enumerable string keys, and schema arrays are dense intrinsic arrays, so inference, compilation, and validation observe the same declaration. Inference stays exact through 16 container levels and then widens to `JsonValue`; runtime validation keeps walking the complete schema. `valueSchemaSpecToJsonSchema()` compiles output declarations through the same enforced raw subset. A parameter mismatch throws `ToolArgsError` (`INVALID_ARGS`); an invalid body or post-policy value throws `ToolOutputError` (`INVALID_TOOL_OUTPUT`). Both use the normal tool-error path. Raw JSON Schema remains open by default; unsupported keywords reject instead of being accepted without enforcement. -Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input, requires `output`, validates its raw schema, and checks semantic requirements such as a positive finite `timeoutMs`; `schemas()` materializes the explicit model-facing projection at the model boundary so execution and presentation share one resolved definition without leaking callbacks onto the wire. +Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input, requires `output`, validates its raw schema, and checks semantic requirements such as a positive finite `timeoutMs`; `schemas()` constructs the model-facing projection when building a request, so execution and presentation share one resolved definition without leaking callbacks onto the wire. ## `ToolRestriction` — one scope's live global filter @@ -248,7 +248,7 @@ type ToolExecutionMode = | { kind: 'exclusive' } ``` -Code Mode's bridge additionally exposes each settled sub-dispatch to the `tools/code-dispatch-log` waterfall, which may reshape the durable event's copy of the content (the program's value and the model contract are untouched): +Code Mode's bridge additionally exposes each settled sub-dispatch to the `tools/code-dispatch-log` waterfall, which may change the durable event's copy of the content (the program's value and model-visible result remain untouched): ```ts type-equiv /** @@ -306,7 +306,7 @@ interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> { `ToolExecutionToken` is an opaque runtime `Symbol` used only for identity comparison. Before policy, `execute()` materializes and freezes arguments, rejects non-JSON input, and assigns the token. Identity fields, the required caller signal, and the optional parent token remain readonly. A `ToolDispatchExecution` wrapper may replace but not remove the signal; the registry re-fuses the caller signal before invoking the body. Final observers receive the frozen execution identity. -A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. +A `ToolGuard` is scope-aware final pre-dispatch policy. Its return type deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. ```ts type-equiv /** @@ -416,7 +416,7 @@ type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'bo ```ts type-equiv /** * One raw JSON Schema node in the enforced subset. The optional fields express - * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid + * the external wire schema; {@link assertSupportedJsonSchema} rejects invalid * combinations before a caller treats the node as trusted. */ interface JsonSchemaNode { @@ -565,7 +565,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:759`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:760`](../../packages/core/tools/src/index.ts) <a id="tools-events"></a> @@ -590,23 +590,24 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:192`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:193`](../../packages/core/tools/src/index.ts) <a id="toolscode-dispatch-log--waterfall"></a> #### `tools/code-dispatch-log` — waterfall -Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the unshaped content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. +Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. ```ts cordis-catalog /** - * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before - * the bridge appends its `tool/code-dispatch` event. `next()` keeps the + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/code-dispatch` event. `next()` keeps the * content unchanged; a listener may return replacement blocks (e.g. the * spill policy's preview + locator for an oversized text result). Only the * logged copy is affected — the program already received the complete * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the unshaped content. + * the bridge falls back to logging the original settled content. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. * @param dispatch - the parent execution, sub-call identity, and the settled content to log. * @mode waterfall @@ -616,7 +617,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:174`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:175`](../../packages/core/tools/src/index.ts) <a id="toolsexecute--waterfall"></a> @@ -709,5 +710,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:182`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:183`](../../packages/core/tools/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 81aabddd20..87269e5532 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -2,7 +2,7 @@ [English](tools.md) | 中文 -[dsh-tools](../../packages/core/tools) 的工具流水线。[core.md](core.md) 介绍了 `ToolDefinition`(唯一被提升到主干的流水线编写类型);面向模型的 [`ToolSchema`](llm-streaming.md#the-model-request-and-result) 协议格式(wire format)形状与模型请求一起声明。本页拥有完整的 `ToolDefinition`、用于构建它的类型化 schema DSL、受保护的执行形状,以及 UI 展示词汇。 +[dsh-tools](../../packages/core/tools) 的工具处理流程。[core.md](core.md) 介绍了核心包共用的流程编写类型 `ToolDefinition`;面向模型的 [`ToolSchema`](llm-streaming.md#the-model-request-and-result) 协议类型与模型请求一起声明。本页记录 `ToolDefinition` 的每个字段、用于构建它的类型化 schema DSL、带守卫的执行类型和 UI 展示类型。 源码:[`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) · [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts) @@ -148,7 +148,7 @@ type InferArgs<S> = InferProperties<S, []> `defineTool({ name, description, parameters, output, execute, … })` 将参数推导与 `parameterSchemaSpecToJsonSchema()` 和 `validateArgs()` 绑定,并将 `execute`/`render`/`presentationMeta` 与 `InferValue<OutputSchema>` 绑定。Schema 记录只包含自有且可枚举的字符串键,schema 数组是稠密的内建数组,因此推导、编译与校验观察到的是同一份声明。精确推导保持到 16 层容器,之后放宽为 `JsonValue`;运行时校验仍会继续遍历完整 schema。`valueSchemaSpecToJsonSchema()` 通过同一套已强制执行的原始子集编译输出声明。参数不匹配时抛出 `ToolArgsError`(`INVALID_ARGS`);函数体或后置策略产生的值无效时抛出 `ToolOutputError`(`INVALID_TOOL_OUTPUT`)。两者都经由常规工具错误路径处理。原始 JSON Schema 默认保持开放;不支持的关键字会被拒绝,而不会在未强制执行的情况下获准进入。 -注册是一个受信任的同进程约定。注册表以 readonly 输入借用类型化定义,要求它声明 `output`,校验其原始 schema,并检查 `timeoutMs` 必须为正有限值等语义要求;`schemas()` 在模型边界处物化显式的面向模型投影,使执行和展示共享同一份已解析定义,而不会将回调泄漏到协议上。 +注册是一项受信任的同进程约定。注册表以 readonly 输入借用已类型化定义,要求它声明 `output`,校验其原始 schema,并检查 `timeoutMs` 必须为正有限值等语义要求;`schemas()` 在构建请求时生成面向模型的投影,使执行和展示共享同一份已解析定义,而不会将回调泄漏到协议上。 ## `ToolRestriction` — 单个作用域的实时全局过滤器 @@ -248,7 +248,7 @@ type ToolExecutionMode = | { kind: 'exclusive' } ``` -Code Mode 的桥接层还会把每个已结算的子分派暴露给 `tools/code-dispatch-log` waterfall,该 waterfall 可以改写持久事件所存的内容副本(程序取得的值与模型约定均不受影响): +Code Mode 的桥接层还会把每个已结算的子分派暴露给 `tools/code-dispatch-log` waterfall,该 waterfall 可以更改持久事件所存的内容副本(程序取得的值和模型可见结果均不受影响): ```ts type-equiv /** @@ -306,7 +306,7 @@ interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> { `ToolExecutionToken` 是不透明的运行时 `Symbol`,仅用于身份比较。策略执行前,`execute()` 会物化并冻结参数、拒绝非 JSON 输入并分配 token。身份字段、调用方必需的 signal 和可选的 parent token 均保持 readonly。`ToolDispatchExecution` 包装层可以替换 signal 但不能移除;注册表会在调用工具函数体前重新融合调用方的 signal。最终观察者接收冻结的执行身份。 -`ToolGuard` 是感知作用域的最终预分派策略。其形状有意不包含 allow 结果:`undefined` 保留 waterfall 的决策,而返回的 reason 只能缩减权限,因此后续监听器无法撤销它。 +`ToolGuard` 是感知作用域的最终预分派策略。其返回类型有意不包含 allow 结果:`undefined` 保留 waterfall 的决策,而返回的 reason 只能缩减权限,因此后续监听器无法撤销它。 ```ts type-equiv /** @@ -416,7 +416,7 @@ type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'bo ```ts type-equiv /** * One raw JSON Schema node in the enforced subset. The optional fields express - * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid + * the external wire schema; {@link assertSupportedJsonSchema} rejects invalid * combinations before a caller treats the node as trusted. */ interface JsonSchemaNode { @@ -565,7 +565,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:759`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:760`](../../packages/core/tools/src/index.ts) <a id="tools-events"></a> @@ -590,23 +590,24 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:192`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:193`](../../packages/core/tools/src/index.ts) <a id="toolscode-dispatch-log--waterfall"></a> #### `tools/code-dispatch-log` — waterfall -Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the unshaped content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. +Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. ```ts cordis-catalog /** - * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before - * the bridge appends its `tool/code-dispatch` event. `next()` keeps the + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/code-dispatch` event. `next()` keeps the * content unchanged; a listener may return replacement blocks (e.g. the * spill policy's preview + locator for an oversized text result). Only the * logged copy is affected — the program already received the complete * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the unshaped content. + * the bridge falls back to logging the original settled content. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. * @param dispatch - the parent execution, sub-call identity, and the settled content to log. * @mode waterfall @@ -616,7 +617,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:174`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:175`](../../packages/core/tools/src/index.ts) <a id="toolsexecute--waterfall"></a> @@ -709,5 +710,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:182`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:183`](../../packages/core/tools/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/user-interaction.i18n.yaml b/docs/subsystems/user-interaction.i18n.yaml index 296bc8b766..0ea5d31f17 100644 --- a/docs/subsystems/user-interaction.i18n.yaml +++ b/docs/subsystems/user-interaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/user-interaction.md -user-interaction.md: a19155ae06af0133ae468c004b2e3b66f1de3fb8 -user-interaction.zh.md: c7aa9cafc9865191cda4089ad2beb214a33b00ef +user-interaction.md: 1ba4372d2c8ce2d8eeab46ce3aa4acdb11092ad3 +user-interaction.zh.md: 3e7e92d90216516ce1e38c69af88ed71701f1843 diff --git a/docs/subsystems/user-interaction.md b/docs/subsystems/user-interaction.md index a19155ae06..1ba4372d2c 100644 --- a/docs/subsystems/user-interaction.md +++ b/docs/subsystems/user-interaction.md @@ -8,7 +8,7 @@ Source: [`packages/interaction/user-interaction/src/index.ts`](../../packages/in ## Question options -`AskUserQuestionOption` is the selectable-choice shape. `label` is the user-facing option text and also the model-facing selected value; `description` is optional UI help text. +`AskUserQuestionOption` contains one selectable choice. `label` is the user-facing option text and also the model-facing selected value; `description` is optional UI help text. ```ts type-equiv /** One selectable answer offered to the user. */ @@ -22,15 +22,15 @@ interface AskUserQuestionOption { ## Presentation intent -`AskUserQuestionIntent` is the optional declaration that a question IS a decision of a known shape. It is tagged on `kind` so intents can be added; a UI that does not recognise a tag renders the generic option list. An intent shapes presentation only — a UI honouring it answers with the same option labels a generic UI would send, so the caller reads one answer shape either way. `approve` names the affirmative option instead of relying on option order. `ask()` rejects the two assertions no type can carry: an `approve` naming none of its own question's options, and an intent on a question with no `detail`. +`AskUserQuestionIntent` optionally declares a known decision kind. It is tagged on `kind` so intents can be added; a UI that does not recognise a tag renders the generic option list. An intent changes presentation only — a UI honouring it answers with the same option labels a generic UI would send, so the caller reads the same answer fields either way. `approve` names the affirmative option instead of relying on option order. `ask()` rejects the two assertions no type can carry: an `approve` naming none of its own question's options, and an intent on a question with no `detail`. ```ts type-equiv /** - * A caller-declared presentation intent: the question IS a decision of this - * shape, so a UI that recognises the tag may present it as such instead of as a + * A caller-declared presentation intent: the question IS this kind of + * decision, so a UI that recognises the tag may present it as such instead of as a * generic option list. Tagged so further intents can be added; a UI that does * not know a tag renders the generic flow, and the answer encoding is identical - * either way — an intent shapes presentation only, never the protocol. + * either way — an intent changes presentation only, never the protocol. */ type AskUserQuestionIntent = { /** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */ diff --git a/docs/subsystems/user-interaction.zh.md b/docs/subsystems/user-interaction.zh.md index c7aa9cafc9..3e7e92d902 100644 --- a/docs/subsystems/user-interaction.zh.md +++ b/docs/subsystems/user-interaction.zh.md @@ -8,7 +8,7 @@ ## 问题选项 -`AskUserQuestionOption` 是可选择项的形状。`label` 是面向用户的选项文字,同时也是面向模型的选中值;`description` 是可选的 UI 帮助文本。 +`AskUserQuestionOption` 包含一个可供选择的选项。`label` 是面向用户的选项文字,同时也是面向模型的选中值;`description` 是可选的 UI 帮助文本。 ```ts type-equiv /** One selectable answer offered to the user. */ @@ -22,15 +22,15 @@ interface AskUserQuestionOption { ## 呈现意图 -`AskUserQuestionIntent` 是一项可选声明:某个问题本身就是一次已知形状的决定。它按 `kind` 打标签,因此意图可以扩充;不认识某个标签的 UI 渲染通用选项列表。意图只塑造呈现 —— 遵循它的 UI 回答的仍是通用 UI 会发送的那些 option label,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名肯定选项,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上。 +`AskUserQuestionIntent` 可选地声明一种已知的决定类型。它按 `kind` 打标签,因此可以增加新的意图;不认识某个标签的 UI 渲染通用选项列表。意图只改变呈现方式——遵循它的 UI 回答的仍是通用 UI 会发送的那些 option label,因此调用方两种情况下读到的回答字段相同。`approve` 指名肯定选项,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上。 ```ts type-equiv /** - * A caller-declared presentation intent: the question IS a decision of this - * shape, so a UI that recognises the tag may present it as such instead of as a + * A caller-declared presentation intent: the question IS this kind of + * decision, so a UI that recognises the tag may present it as such instead of as a * generic option list. Tagged so further intents can be added; a UI that does * not know a tag renders the generic flow, and the answer encoding is identical - * either way — an intent shapes presentation only, never the protocol. + * either way — an intent changes presentation only, never the protocol. */ type AskUserQuestionIntent = { /** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */ diff --git a/docs/subsystems/web.i18n.yaml b/docs/subsystems/web.i18n.yaml index 8a11d774e6..bf0eef9ce4 100644 --- a/docs/subsystems/web.i18n.yaml +++ b/docs/subsystems/web.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/web.md -web.md: 2a7cc499b42dd8b45ce6783a35a2fadd8df1183d -web.zh.md: 6857f2a06a7d90702b6626409b4d8db914b3c465 +web.md: a411fc804133b012b77b7232e653decdb0f09c3d +web.zh.md: 595c315cf36dbbdf5b9a26870d5aeb1bbc0bb405 diff --git a/docs/subsystems/web.md b/docs/subsystems/web.md index 2a7cc499b4..a411fc8041 100644 --- a/docs/subsystems/web.md +++ b/docs/subsystems/web.md @@ -125,7 +125,7 @@ Selection never depends on registration, config, or HMR order: a capability has ## Errors -`WebError extends HarnessError` ([core.md](core.md) error taxonomy) with a `code: string` (open, like every other seam's error — `LlmError`, `SubagentError`), not a closed union: a provider may raise its own codes without editing `dsh-web`, and consumers must tolerate an unknown code. The codes split by owner. Seam-neutral codes are raised by `WebService` selection and the shared contract: `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`, `WEB_DUPLICATE_PROVIDER` (a registration-time programming error, the analogue of `LlmService`'s `DUPLICATE_ADAPTER`), `WEB_ABORTED`, and `WEB_PROVIDER_ERROR` (the catch-all for a provider's own failure surfaced through the seam, including network/transport failure — DNS, connection refused, TLS). Fetch-transport codes are owned by the `dsh-web-fetch-local` implementation and a different fetch backend need not raise them: `WEB_INVALID_URL`, `WEB_BLOCKED_URL`, `WEB_REDIRECT_BLOCKED`, `WEB_FETCH_TOO_LARGE`, `WEB_FETCH_TIMEOUT`, `WEB_UNSUPPORTED_CONTENT_TYPE`. +`WebError extends HarnessError` ([core.md](core.md) error taxonomy) with a `code: string` (open, like every other seam's error — `LlmError`, `SubagentError`), not a closed union: a provider may raise its own codes without editing `dsh-web`, and consumers must tolerate an unknown code. The codes split by owner. Seam-neutral codes are raised by the shared `WebService` contract: `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`, `WEB_DUPLICATE_PROVIDER` (a registration-time programming error, the analogue of `LlmService`'s `DUPLICATE_ADAPTER`), `WEB_ABORTED`, and `WEB_PROVIDER_ERROR` (the catch-all for a provider's own failure surfaced through the seam, including network/transport failure — DNS, connection refused, TLS). Fetch-transport codes are owned by the `dsh-web-fetch-local` implementation and a different fetch backend need not raise them: `WEB_INVALID_URL`, `WEB_BLOCKED_URL`, `WEB_REDIRECT_BLOCKED`, `WEB_FETCH_TOO_LARGE`, `WEB_FETCH_TIMEOUT`, `WEB_UNSUPPORTED_CONTENT_TYPE`. ## The service @@ -178,7 +178,7 @@ registerFetchProvider(provider: WebFetchProvider): () => void * time with the selection rules above; throws {@link WebError} when the * capability cannot run. The seam enforces `request.maxResults` on the result: * if the provider over-returns, `sources[]` is truncated and `truncated` set. - * @param request - the query plus result-shaping options. + * @param request - the query and optional result limit. * @param signal - optional cancellation signal forwarded to the provider. * @returns the provider's results, capped to `request.maxResults`. */ diff --git a/docs/subsystems/web.zh.md b/docs/subsystems/web.zh.md index 6857f2a06a..595c315cf3 100644 --- a/docs/subsystems/web.zh.md +++ b/docs/subsystems/web.zh.md @@ -125,7 +125,7 @@ type WebFetchBody = ## 错误 -`WebError extends HarnessError`([core.md](core.md) 错误分类体系),带有 `code: string`(开放式,与其他 seam 的错误一致——`LlmError`、`SubagentError`),而非封闭联合类型:提供方可以在不修改 `dsh-web` 的情况下抛出自己的错误代码,消费方必须容忍未知错误代码。错误代码按所有者划分。由 seam 统一定义的错误代码来自 `WebService` 的选择逻辑和共享约定:`WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`、`WEB_PROVIDER_AMBIGUOUS`、`WEB_DUPLICATE_PROVIDER`(注册时的编程错误,类似 `LlmService` 的 `DUPLICATE_ADAPTER`)、`WEB_ABORTED`,以及 `WEB_PROVIDER_ERROR`(提供方自身故障经 seam 暴露时使用的兜底代码,包括 DNS、连接被拒绝、TLS 等网络或传输故障)。抓取传输层错误代码由 `dsh-web-fetch-local` 实现拥有,不同的抓取后端无需抛出它们:`WEB_INVALID_URL`、`WEB_BLOCKED_URL`、`WEB_REDIRECT_BLOCKED`、`WEB_FETCH_TOO_LARGE`、`WEB_FETCH_TIMEOUT`、`WEB_UNSUPPORTED_CONTENT_TYPE`。 +`WebError extends HarnessError`([core.md](core.md) 错误分类体系),带有 `code: string`(开放式,与其他 seam 的错误一致——`LlmError`、`SubagentError`),而非封闭联合类型:提供方可以在不修改 `dsh-web` 的情况下抛出自己的错误代码,消费方必须容忍未知错误代码。错误代码按所有者划分。共享的 `WebService` 约定会抛出与 seam 无关的错误代码:`WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`、`WEB_PROVIDER_AMBIGUOUS`、`WEB_DUPLICATE_PROVIDER`(注册时的编程错误,类似 `LlmService` 的 `DUPLICATE_ADAPTER`)、`WEB_ABORTED`,以及 `WEB_PROVIDER_ERROR`(提供方自身故障经 seam 暴露时使用的兜底代码,包括 DNS、连接被拒绝、TLS 等网络或传输故障)。抓取传输层错误代码由 `dsh-web-fetch-local` 实现拥有,不同的抓取后端无需抛出它们:`WEB_INVALID_URL`、`WEB_BLOCKED_URL`、`WEB_REDIRECT_BLOCKED`、`WEB_FETCH_TOO_LARGE`、`WEB_FETCH_TIMEOUT`、`WEB_UNSUPPORTED_CONTENT_TYPE`。 ## 服务 @@ -178,7 +178,7 @@ registerFetchProvider(provider: WebFetchProvider): () => void * time with the selection rules above; throws {@link WebError} when the * capability cannot run. The seam enforces `request.maxResults` on the result: * if the provider over-returns, `sources[]` is truncated and `truncated` set. - * @param request - the query plus result-shaping options. + * @param request - the query and optional result limit. * @param signal - optional cancellation signal forwarded to the provider. * @returns the provider's results, capped to `request.maxResults`. */ diff --git a/docs/subsystems/workflow.i18n.yaml b/docs/subsystems/workflow.i18n.yaml index f1ddf20d11..b18eeced08 100644 --- a/docs/subsystems/workflow.i18n.yaml +++ b/docs/subsystems/workflow.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/workflow.md -workflow.md: 4c552b31435963d4133a31f2e98f8a913305c1f8 -workflow.zh.md: 53ef84134b00e323bd5331ff30b18012124b5241 +workflow.md: 22dcaad608cc2ca7f407b8837fc3856abcc43555 +workflow.zh.md: 7ccd47f414ad574f2daa8e74f6cfb65abfbe06c2 diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 4c552b3143..22dcaad608 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -2,7 +2,7 @@ English | [中文](workflow.zh.md) -The workflow seam — an agent running a model-written orchestration SCRIPT that fans out subagents. Like [subagent](subagent.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). Unlike the subagent registry it takes the bash shape: ONE engine implementation per context provides `ctx.workflows`; there is no named-provider registry (a second engine is a plugin swap, not a co-resident). +The workflow seam lets an agent run a model-written orchestration SCRIPT that starts subagents. Like [subagent](subagent.md) it is **one optional capability**, not part of the agent loop, so its types and operations live here rather than in [core.md](core.md). Like bash, it permits ONE engine implementation per context to provide `ctx.workflows`; there is no named-provider registry (a second engine replaces the first through plugin configuration rather than running beside it). Service Definition: [dsh-workflow](../../packages/workflow/workflow) (`ctx.workflows` + the vocabulary below). The Service provider is [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread) (a `node:worker_threads` engine — one worker per run, the script's vm context inside it); the model-facing Consumer is [dsh-tool-workflow](../../packages/workflow/tool-workflow). The proposal and rationale: [the dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md). @@ -10,13 +10,13 @@ Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/work ## The start request -What a caller asks for when starting a run. The ordinary workflow tool builds this from the model's `{ script, meta, args }` call plus the calling agent; specialized consumers may also select one engine-wide `subagentProvider` and lower `maxTotalAgents` for the run, but the script cannot observe or replace either policy. `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). +What a caller asks for when starting a run. The ordinary workflow tool builds this from the model's `{ script, meta, args }` call plus the calling agent; specialized consumers may also select one engine-wide `subagentProvider` and lower `maxTotalAgents` for the run, but the script cannot observe or replace either policy. `meta` and `args` are plain JSON DATA (the engine validates `meta` against its schema and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script starts is attributed to it, and cwd, lineage, and depth pass through the [subagent seam](subagent.md). ```ts type-equiv /** * What a caller asks for when starting a workflow run. `meta` and `args` are - * plain JSON DATA by the seam contract (the tool builds both from the model's - * schema-validated call; the engine validates `meta`'s shape and rejects loud + * plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call; + * the engine validates `meta` against its schema and rejects loud * before anything runs) — an engine never evaluates script text to obtain * them. `parent` is REQUIRED — every `agent()` the script spawns is * attributed to it (cwd, lineage, depth flow through the subagent seam). @@ -24,7 +24,7 @@ What a caller asks for when starting a run. The ordinary workflow tool builds th interface WorkflowStartRequest { /** The plain-JS script body (top-level await allowed; ends with `return <json-value>`). */ script: string - /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ + /** The workflow's identity fields as plain JSON data, validated by the engine. */ meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown diff --git a/docs/subsystems/workflow.zh.md b/docs/subsystems/workflow.zh.md index 53ef84134b..7ccd47f414 100644 --- a/docs/subsystems/workflow.zh.md +++ b/docs/subsystems/workflow.zh.md @@ -2,7 +2,7 @@ [English](workflow.md) | 中文 -工作流 seam 允许 agent(智能体)运行由模型编写的编排脚本,并由该脚本扇出 subagent。与 [subagent](subagent.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此处而非 [core.md](core.md)。与 subagent 注册表不同,它采用 bash 形态:每个上下文只有一个引擎实现提供 `ctx.workflows`;没有命名提供方注册表(第二个引擎是插件替换,而非共存)。 +工作流 seam 允许 agent(智能体)运行由模型编写、会启动 subagent 的编排脚本。与 [subagent](subagent.md) 一样,它是**一项可选能力**,不属于 agent loop,因此其类型和操作记录在此处,而非 [core.md](core.md)。与 bash 一样,每个上下文只允许一个引擎实现提供 `ctx.workflows`;没有命名提供方注册表(第二个引擎通过插件配置替换第一个,而不与它同时运行)。 Service Definition:[dsh-workflow](../../packages/workflow/workflow)(`ctx.workflows` + 下文词汇)。Service provider 是 [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread)(一个 `node:worker_threads` 引擎——每个 run 一个 worker,脚本的 vm 上下文位于其中);面向模型的 Consumer 是 [dsh-tool-workflow](../../packages/workflow/tool-workflow)。提案与设计理由见 [dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。 @@ -10,13 +10,13 @@ Service Definition:[dsh-workflow](../../packages/workflow/workflow)(`ctx.wor ## 启动请求 -本节定义调用方启动一次运行时提交的请求。普通工作流工具会根据模型的 `{ script, meta, args }` 调用和发起调用的 agent 构建该请求;专用消费方还可以为本次运行选择引擎级 `subagentProvider`,并将 `maxTotalAgents` 调低,但脚本无法观察或替换这两项策略。`meta` 与 `args` 是普通 JSON 数据;引擎会校验 `meta` 的形状,并在任何工作开始前大声拒绝无效数据。引擎绝不会通过对脚本文本求值来获取它们。`parent` 是必填字段——脚本生成的每个子 agent 都归属于它(cwd、谱系与深度通过 [subagent seam](subagent.md) 流转)。 +本节定义调用方启动一次运行时提交的请求。普通工作流工具会根据模型的 `{ script, meta, args }` 调用和发起调用的 agent 构建该请求;专用消费方还可以为本次运行选择引擎级 `subagentProvider`,并将 `maxTotalAgents` 调低,但脚本无法观察或替换这两项策略。`meta` 与 `args` 是普通 JSON 数据;引擎会用 schema 校验 `meta`,并在任何工作开始前拒绝无效数据。引擎绝不会通过对脚本文本求值来获取它们。`parent` 是必填字段——脚本启动的每个子 agent 都归属于它,cwd、谱系与深度通过 [subagent seam](subagent.md) 传递。 ```ts type-equiv /** * What a caller asks for when starting a workflow run. `meta` and `args` are - * plain JSON DATA by the seam contract (the tool builds both from the model's - * schema-validated call; the engine validates `meta`'s shape and rejects loud + * plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call; + * the engine validates `meta` against its schema and rejects loud * before anything runs) — an engine never evaluates script text to obtain * them. `parent` is REQUIRED — every `agent()` the script spawns is * attributed to it (cwd, lineage, depth flow through the subagent seam). @@ -24,7 +24,7 @@ Service Definition:[dsh-workflow](../../packages/workflow/workflow)(`ctx.wor interface WorkflowStartRequest { /** The plain-JS script body (top-level await allowed; ends with `return <json-value>`). */ script: string - /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ + /** The workflow's identity fields as plain JSON data, validated by the engine. */ meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 8809d62cd3..2b608ec739 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/testing.md -testing.md: 62ac375844b7f05970e944278fd44ac60f68b4b1 -testing.zh.md: 6353b2799b5760d8c45534d0264bf41be063d659 +testing.md: f5e8a478ec86c29c52f4127c51682c1c44fd23a7 +testing.zh.md: bd1fa7d23263d7c6e3bed65ef4ed09576ca47cc1 diff --git a/docs/testing.md b/docs/testing.md index 62ac375844..f5e8a478ec 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -6,7 +6,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning ## Tiers -- **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). +- **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent tests for contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/bash/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless backend scenarios boot their explicit example composition through an unexported JSONL test driver, while `apps/cli` separately owns product `dsh run` acceptance. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). @@ -30,8 +30,8 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## Test the real entry path -- Product-visible plugins require a non-unit REAL-composition test. Hand-built `ctx.plugin(...)` suites are insufficient: boot test-only `cordis.yml` through Loader and app/process, mock only external/nondeterministic boundaries, and assert model-visible request/log, durable state, or user-visible output. Keep opt-ins out of shipped defaults. -- A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. +- Product-visible plugins require a non-unit REAL-composition test. Hand-built `ctx.plugin(...)` suites are insufficient: boot test-only `cordis.yml` through Loader and app/process, mock only external services or nondeterministic inputs, and assert model-visible request/log, durable state, or user-visible output. Keep opt-ins out of shipped defaults. +- A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green when a default export replaces the required named exports — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. - "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/scaffold/server/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/examples/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. ## Test resolution: source plane only @@ -46,4 +46,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples/<name>/tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the internal canonical-event JSONL snapshots and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples/<name>/tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the internal canonical-event JSONL snapshots and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle variants, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 6353b2799b..bd1fa7d232 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -6,7 +6,7 @@ ## 层级 -- **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性约定回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 +- **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及针对约定回归的永久测试(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/bash/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其 executor 套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 - **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输约定与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 后端场景通过未导出的 JSONL 测试 driver 启动各自显式的示例组装,而 `apps/cli` 则单独负责产品 CLI(命令行界面)`dsh run` 的验收。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 @@ -30,8 +30,8 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 测试真实入口路径 -- 产品可见的插件必须有一个非单元的真实组合测试。手动构建的 `ctx.plugin(...)` 套件不够:通过 Loader 和 app/process 启动仅用于测试的 `cordis.yml`,只 mock 外部/不确定边界,断言模型可见的请求/日志、持久状态或用户可见输出。不要把 opt-in 选项混入交付默认值。 -- 一个守卫只有在回归真的能让它失败时才有效。对于没有 `inject` 的插件(bundle/组合插件),Loader 冒烟测试在导出形状损坏时仍然绿着——需要添加显式的 `expect('default' in mod).toBe(false)` 加 `unwrapExports` 往返断言,并证明它有效:引入回归、观察变红、回退。 +- 产品可见的插件必须有一个非单元的真实组合测试。手动构建的 `ctx.plugin(...)` 套件不够:通过 Loader 和 app/process 启动仅用于测试的 `cordis.yml`,只 mock 外部服务或非确定性输入,断言模型可见的请求/日志、持久状态或用户可见输出。不要把 opt-in 选项混入交付默认值。 +- 一个守卫只有在回归真的能让它失败时才有效。对于没有 `inject` 的插件(bundle/组合插件),Loader 冒烟测试在默认导出替换必需的具名导出时仍然绿着——需要添加显式的 `expect('default' in mod).toBe(false)` 加 `unwrapExports` 往返断言,并证明它有效:引入回归、观察变红、回退。 - 「真实入口路径」指已发布的产物:包的 `bin` 所运行的是构建后的 `lib/bin.js`,并由普通 `node` 执行,从而暴露 tsx 会掩盖的失败(等待稳定时的竞态、模块解析、被吞掉的加载失败)。同样的规则适用于非 index 运行时入口(worker-thread 的同级文件 `lib/worker.cjs`),也适用于多个 bundle 共享的单例模块(`packages/scaffold/server/tests/built-scope-carrier.e2e.ts`)。保持构建产物冒烟测试绿色(`packages/examples/*/tests/built-bin.e2e.ts`、`packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`),并断言真正缺失的配置以非零状态退出。 ## 测试解析:仅限源码 @@ -46,4 +46,4 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 何时需要快照测试 -每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples/<name>/tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有内部规范事件 JSONL 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 +每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples/<name>/tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有内部规范事件 JSONL 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期变体或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 diff --git a/docs/tool-execution-pipeline.i18n.yaml b/docs/tool-execution-pipeline.i18n.yaml index 037e063125..b629e4029e 100644 --- a/docs/tool-execution-pipeline.i18n.yaml +++ b/docs/tool-execution-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-execution-pipeline.md -tool-execution-pipeline.md: 16a4461b6c995f666882635dddb25e1a6cf79c73 -tool-execution-pipeline.zh.md: 3b5226c5ea8038c0fc5154cd37a77d92f6e3d0e1 +tool-execution-pipeline.md: 6c925a404d7a161838e72ce4b03b7f2cad29d313 +tool-execution-pipeline.zh.md: 15627023d3be6ac2b3aae70c2ef01ef9f1077d3e diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index 16a4461b6c..6c925a404d 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -3,7 +3,7 @@ # Tool Execution Pipeline -This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards, definition-owned `finalizeContent`, and `tools/result` are the owner-enforced boundaries around them. +This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `finalizeContent` and `tools/result` run afterward. ```mermaid flowchart TD diff --git a/docs/tool-execution-pipeline.zh.md b/docs/tool-execution-pipeline.zh.md index 3b5226c5ea..15627023d3 100644 --- a/docs/tool-execution-pipeline.zh.md +++ b/docs/tool-execution-pipeline.zh.md @@ -5,7 +5,7 @@ [English](tool-execution-pipeline.md) | 中文 -此图展示了策略、钩子、沙箱、文件系统守卫、结果重写、最终结果观察和 UI 渲染如何在不改变循环的前提下各就其位。可转换的扩展点是 `tools/pre-execute`、`tools/execute` 和 `tools/post-execute` waterfall(瀑布式事件);围绕这些扩展点的边界则由所有者强制执行,包括单调守卫、由定义自身控制的 `finalizeContent`,以及 `tools/result`。 +此图展示策略、钩子、沙箱、文件系统守卫、结果重写、最终结果观察和 UI 渲染在不改变循环的情况下何时运行。`tools/pre-execute` waterfall(瀑布式事件)首先运行,随后是单调守卫,然后运行 `tools/execute` 和 `tools/post-execute` waterfall;这三个 waterfall 可以改写一次调用。由定义自身控制的 `finalizeContent` 和 `tools/result` 在此之后运行。 ```mermaid flowchart TD diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml index 2bb1d0ce7d..0d89e9622e 100644 --- a/docs/user/develop/basic/index.i18n.yaml +++ b/docs/user/develop/basic/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/index.md -index.md: efedb07c8d757ef1f90d99fe1bf503a35c0f1a37 -index.zh.md: 2293a6086dc80fa77c88ef734ae17576ea513a10 +index.md: 7fe66bb19ddb978a4b5a96768b62151b97bca0ec +index.zh.md: 59f4e5b58b6cf1fbc15de8fafb5f4b0db2e220d6 diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md index efedb07c8d..7fe66bb19d 100644 --- a/docs/user/develop/basic/index.md +++ b/docs/user/develop/basic/index.md @@ -26,7 +26,7 @@ export function apply(ctx: Context) { } ``` -That is the complete shape. +That is the complete configuration. ## Create the plugin file diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md index 2293a6086d..59f4e5b58b 100644 --- a/docs/user/develop/basic/index.zh.md +++ b/docs/user/develop/basic/index.zh.md @@ -26,7 +26,7 @@ export function apply(ctx: Context) { } ``` -这就是完整结构。 +这就是完整配置。 ## 创建插件文件 diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index b387f7a0b6..d849ac4ae0 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/publish.md -publish.md: 1d1179a78c4d3a7e9e7055e3f5ee41381e28147e -publish.zh.md: d683762b78920dc754b6871ed7bd0caf3ee9afd4 +publish.md: 7657654b1467c14b22e0eb6372c2bc4e77db2f38 +publish.zh.md: 7af2ae3a06cc74597d5cbd6fddd46fbab069e287 diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 1d1179a78c..7657654b14 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -33,7 +33,7 @@ hello-plugin/ } ``` -The patch file has the same shape as the `--patch` overlays you have been writing — a YAML array of patch entries — except plugin rows reference the package by name instead of a relative source path, so Node resolution finds the installed code: +The patch file is a YAML array of patch entries, like the `--patch` overlays you have been writing, except plugin rows reference the package by name instead of a relative source path so Node resolution finds the installed code: ```yaml - insert: @@ -41,7 +41,7 @@ The patch file has the same shape as the `--patch` overlays you have been writin name: dsh-hello-plugin ``` -A package without the `dsh.bundle` declaration still installs, but only as a plain dependency: `dsh plugin` prints a warning and activates no layer. That is the correct shape for a library that plugin packages import rather than a plugin users enable. +A package without the `dsh.bundle` declaration still installs, but only as a plain dependency: `dsh plugin` prints a warning and activates no layer. Use that package format for a library that plugin packages import rather than a plugin users enable. ### The profile manifest diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index d683762b78..7af2ae3a06 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -33,7 +33,7 @@ hello-plugin/ } ``` -patch 文件的形状与你一直在写的 `--patch` overlay 相同——一个 patch 条目的 YAML 数组——只是插件行按包名而不是相对源码路径引用这个包,这样 Node 的模块解析才能找到已安装的代码: +patch 文件与一直在写的 `--patch` overlay 一样,是一个 patch 条目的 YAML 数组;区别是插件行按包名而不是相对源码路径引用这个包,这样 Node 的模块解析才能找到已安装的代码: ```yaml - insert: @@ -41,7 +41,7 @@ patch 文件的形状与你一直在写的 `--patch` overlay 相同——一个 name: dsh-hello-plugin ``` -没有 `dsh.bundle` 声明的包仍然可以安装,但只作为普通依赖:`dsh plugin` 会打印警告,且不激活任何层。这正是「供插件包 import 的库」应有的形状,区别于「供用户启用的插件」。 +没有 `dsh.bundle` 声明的包仍然可以安装,但只作为普通依赖:`dsh plugin` 会打印警告,且不激活任何层。如果一个库供插件包 import,而不是供用户启用,就使用这种包格式。 ### profile manifest diff --git a/docs/user/develop/practice/index.i18n.yaml b/docs/user/develop/practice/index.i18n.yaml index 24ae066e89..fc15dfeb2f 100644 --- a/docs/user/develop/practice/index.i18n.yaml +++ b/docs/user/develop/practice/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/practice/index.md -index.md: 6ae9fd152ca2e3b82bdb1ea9b3aa3d348ebfce66 -index.zh.md: 0056a81402761310fe5f0463b83762d98ed9745f +index.md: 1eb33e17ab6c5d0a2b37ff97d5948dfbcba497ca +index.zh.md: 31afa80407f81f571615b5ed68a9370775f5188f diff --git a/docs/user/develop/practice/index.md b/docs/user/develop/practice/index.md index 6ae9fd152c..1eb33e17ab 100644 --- a/docs/user/develop/practice/index.md +++ b/docs/user/develop/practice/index.md @@ -12,8 +12,8 @@ When a capability is general enough to need replaceable providers, such as Bash The Bash execution capability consists of: -- **Service Definition** (`dsh-bash`) — defines the Cordis service and Bash request/result vocabulary -- **Service provider** (`dsh-bash-local`) — supplies local command execution +- **Service Definition** (`dsh-bash`) — defines the Cordis service and Bash request and result types +- **Service provider** (`dsh-bash-local`) — executes commands on the local machine - **Consumer** (`dsh-tool-bash`) — exposes the capability as a model-callable tool ``` @@ -43,7 +43,7 @@ The Service Definition and tool remain unchanged while the provider changes. ### Evolve independently -- The Service Definition changes rarely after its contract stabilizes. +- The Service Definition changes rarely after callers depend on its contract. - Service providers can improve performance and security independently. - Consumers can change how they present the capability to the model. diff --git a/docs/user/develop/practice/index.zh.md b/docs/user/develop/practice/index.zh.md index 0056a81402..31afa80407 100644 --- a/docs/user/develop/practice/index.zh.md +++ b/docs/user/develop/practice/index.zh.md @@ -12,8 +12,8 @@ 以 Bash 执行能力为例: -- **Service Definition** (`dsh-bash`):定义 Cordis 服务以及 Bash 请求/结果词汇 -- **Service provider** (`dsh-bash-local`):提供本地命令执行 +- **Service Definition** (`dsh-bash`):定义 Cordis 服务以及 Bash 请求和结果类型 +- **Service provider** (`dsh-bash-local`):在本地计算机上执行命令 - **Consumer** (`dsh-tool-bash`):将该能力公开为模型可调用的工具 ``` @@ -43,8 +43,8 @@ ### 独立演进 -- Service Definition 的约定稳定后很少改动 -- Service provider 可以独立优化性能和安全性 +- 调用方开始依赖 Service Definition 的约定后,Service Definition 很少改动。 +- Service provider 可以独立优化性能和安全性。 - Consumer 可以调整能力向模型呈现的方式。 ### 依赖解耦 diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index 55277809ef..fc6744dea1 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/web-styling.md web-styling.md: 5296cc7f83f712532262eadda6da098ae9f63ec7 -web-styling.zh.md: bec623911269cbd7020c26d5574ee88ca074b5d8 +web-styling.zh.md: ed0906dae28a5a31f2e8e094b634ce6d9b518f31 diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index bec6239112..ed0906dae2 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -8,7 +8,7 @@ [`ui-theme`](../packages/client/ui-theme/README.md) 负责 `--dsw-*` 静态色阶、语义别名、排版、动效、渐变、阴影、滚动条样式以及明暗主题偏好。[`ui-layout`](../packages/client/ui-layout/README.md) 将解析后的主题快照应用到文档。功能包使用语义别名,不得另行定义全局主题。 -全局样式表归 `ui-theme/src/styles/` 所有。组件样式以 CSS Modules 形式放在组件旁。当某个值属于组件自身的布局或呈现约定时,组件可以定义局部自定义属性;共享颜色、排版、层级和动效属于主题包。 +全局样式表归 `ui-theme/src/styles/` 所有。组件样式以 CSS Modules 形式放在组件旁。当某个值属于该组件的布局或呈现约定时,组件可以定义局部自定义属性;共享颜色、排版、层级和动效属于主题包。 ## 组件规则 diff --git a/examples/AGENTS.md b/examples/AGENTS.md index a1f87ac8fb..c1dfd47b4d 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -8,7 +8,7 @@ Extract reusable logic into `packages/`, where per-file coverage and README gate Each example has both: -- **Keyless:** boot the real `cordis.yml` through the Loader, drive it, and assert output and clean exit. Catches Loader/export-shape failures hand-mounted tests miss ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). +- **Keyless:** boot the real `cordis.yml` through the Loader, drive it, and assert output and clean exit. Catches invalid Loader exports that hand-mounted tests miss ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **With-key:** send a live-model prompt and verify external state, not the model's claim. Self-skip without `DEEPSEEK_API_KEY`; see [testing.md](../docs/testing.md). Keyless process smokes use `@deepseek-ai/dsh-loader-smoke` for Loader launch resolution; terminal tests wrap that launch in a pseudo-terminal. Tests supply paths, environment, input, and assertions. Every checked-in test Cordis config lives under its corresponding `examples/<agent>/` leaf. Map a package-owned config to `examples/<agent>/tests/fixtures/<group>/<package>/cordis.yml`, keep its driver and assertions package-local, and declare every package it names in both root `tsconfig.json` references and `examples/package.json`. diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index af044fc58f..9eb71d8948 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -236,7 +236,7 @@ const SCENARIOS: Scenario[] = [ // and then `migrate:packed-session-fixtures`, which canonicalizes the live // log's eager-drain-packed rows into the maximal-run layout replay produces. // The recorded fixture's `request/header` config and `request/context` are - // normalized to the replay-produced minimal shape (the live adapter logs + // normalized to the minimal fields produced during replay (the live adapter logs // model capabilities like maxTokens/reasoningEffort that llm-replay has no // data for), and its tool-result paths are canonicalized to `/` separators. { diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index b94a78aa30..9f2bbf5f96 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -20,7 +20,7 @@ import { cleanupAcpExampleTest } from './cleanup.ts' * * Keyless smoke: boot the REAL `cordis.yml` through the `dsh-acp-agent` bin as * an ACP subprocess and drive initialize + session/new — the real-Loader-path - * guard (postmortem 0001) for THIS tree's export shapes, including the + * guard (postmortem 0001) for THIS tree's exports, including the * sandbox executor AND the approval service. No prompt is sent, so neither the * model nor a sandbox runner is ever exercised. * @@ -74,8 +74,8 @@ function launchExampleAcpAgent( requestPermission(params) { permissionRequests.push(params) const option = params.options.find(o => o.optionId === answer) - // The scripted machine policy selects the requested option; an - // unexpected request shape cancels (fail closed, never grants). + // The scripted machine policy selects the requested option. If that + // option is absent, the policy cancels (fail closed, never grant). if (option === undefined) return Promise.resolve({ outcome: { outcome: 'cancelled' } }) return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } }) }, @@ -107,7 +107,7 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa const { client } = spawned // A dummy key boots the adapter; no prompt is ever sent, so no model call // and no sandbox runner probe happen. This drives the fiber tree the same - // way an ACP caller would, which catches a broken export/inject shape. + // way an ACP caller would, which catches broken exports or injection. const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) expect(init.protocolVersion).toBe(PROTOCOL_VERSION) expect(init.agentCapabilities).toEqual({ diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index ba75ce9294..f855c958bf 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -67,7 +67,7 @@ describe('headless-agent keyless smoke', () => { it('keeps the checked-in prepared wrapper identical to the generator output for its manifest', async () => { // The fixture claims "Generated by dsh-plugin-prepare"; this pin makes the // claim true — a wrapper-template change fails here until the fixture is - // regenerated, so the assembled smoke can never exercise a stale shape. + // regenerated, so the assembled smoke can never exercise stale generated fields. const fixture = fileURLToPath(new URL('./fixtures/repository-plugin/', import.meta.url)) const root = await mkdtemp(join(tmpdir(), 'dsh-fixture-drift-')) try { diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index 7e8e5248e4..d1de74a676 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/mcp-memory/README.md -README.md: 792bb31b668b427c8734286878a9ec98071190d8 -README.zh.md: 51020f5288c4fbd245914280b8e7e4772e8cad69 +README.md: 58da672030eaf2ddf70ee92d506de300efcd9650 +README.zh.md: 0a2f109f9458ec7e1aba50e7fc9b6fd0fca15dbd diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index 792bb31b66..58da672030 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -10,7 +10,7 @@ These third-party configurations are provided as interoperability examples only. DSH parses the selected Cordis overlay, starts a configured stdio command or connects to a configured Streamable HTTP URL, discovers MCP tools, and exposes them as `mcp__<serverName>__<tool>`. DSH does **not** download the server, initialize its database, choose its model or embedding provider, create a cloud account, migrate vendor data, or supervise a separate HTTP service. For stdio, the generic client launches and stops the child with the DSH plugin lifecycle; for HTTP, the upstream service must already be running. -The stdio bridge deliberately removes ambient credential-shaped and `DSH_*` variables before launching a child; other ambient variables remain inherited. Each example adds only the baseline override it needs. If an optional upstream feature needs another secret, add that variable to the row's `config.env` instead of putting the secret directly in YAML. +The stdio bridge deliberately removes ambient variables whose names usually identify credentials and all `DSH_*` variables before launching a child; other ambient variables remain inherited. Each example adds only the baseline override it needs. If an optional upstream feature needs another secret, add that variable to the row's `config.env` instead of putting the secret directly in YAML. ## Choose one @@ -95,7 +95,7 @@ A new DSH session is required; a Host restart is not. Restart or HMR is needed o ## Bring another MCP server -Copy the same generic shape and use a unique `id` and `serverName`: +Copy the same entry fields and use a unique `id` and `serverName`: ```yaml - insert: diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 51020f5288..0a2f109f94 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -10,7 +10,7 @@ DSH 解析选中的 Cordis overlay,启动已配置的 stdio 命令或连接已配置的 Streamable HTTP URL,发现 MCP 工具,并以 `mcp__<serverName>__<tool>` 的形式公开这些工具。DSH **不负责** 下载服务器、初始化其数据库、选择模型或 embedding 提供方、创建云端账户、迁移提供方数据,也不监管独立的 HTTP 服务。对于 stdio,通用客户端会随 DSH 插件生命周期启动和停止子进程;对于 HTTP,上游服务必须已经运行。 -stdio 桥接器在启动子进程前会主动移除环境中名称类似凭据的变量和 `DSH_*` 变量;其余环境变量仍会继承。每份示例仅添加其基线所需的覆盖项。如果某个可选的上游功能还需要其他密钥,请将该变量添加到配置项的 `config.env`,不要把密钥直接写进 YAML。 +stdio 桥接器在启动子进程前会主动移除环境中名称通常表示凭据的变量和所有 `DSH_*` 变量;其余环境变量仍会继承。每份示例仅添加其基线所需的覆盖项。如果某个可选的上游功能还需要其他密钥,请将该变量添加到配置项的 `config.env`,不要把密钥直接写进 YAML。 ## 选择一个 @@ -95,7 +95,7 @@ Engram 负责存储和项目选择:它默认使用 `~/.engram`,从 DSH 工 ## 接入其他 MCP 服务器 -复制相同的通用结构,并使用唯一的 `id` 和 `serverName`: +复制相同的条目字段,并使用唯一的 `id` 和 `serverName`: ```yaml - insert: diff --git a/examples/web-cordis/README.i18n.yaml b/examples/web-cordis/README.i18n.yaml index d2004a499c..d0cc1f4992 100644 --- a/examples/web-cordis/README.i18n.yaml +++ b/examples/web-cordis/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/web-cordis/README.md -README.md: 21fe0a210b2e591a96dc254014a0f91ed9afa2ba -README.zh.md: b3fecb4312dbcbaeff460a21f1d2db6b5288f9ad +README.md: 5d46db7e9d4416f0e6327f7e119bc863ffbdc8a6 +README.zh.md: 9074d8a2dc19b851d85e4fe037f588f70d8da4c7 diff --git a/examples/web-cordis/README.md b/examples/web-cordis/README.md index 21fe0a210b..5d46db7e9d 100644 --- a/examples/web-cordis/README.md +++ b/examples/web-cordis/README.md @@ -18,4 +18,4 @@ Start the ACP automation server instead: pnpm run demo:cordis acp ``` -Both commands require `DEEPSEEK_API_KEY`. The [Cordis tool reference](../../packages/self-modification/tool-cordis/README.md) owns the tool, lifecycle, and safety contracts. +Both commands require `DEEPSEEK_API_KEY`. The [Cordis tool reference](../../packages/self-modification/tool-cordis/README.md) defines the tool arguments, lifetime, cleanup, and safety contracts. diff --git a/examples/web-cordis/README.zh.md b/examples/web-cordis/README.zh.md index b3fecb4312..9074d8a2dc 100644 --- a/examples/web-cordis/README.zh.md +++ b/examples/web-cordis/README.zh.md @@ -18,4 +18,4 @@ pnpm run demo:cordis pnpm run demo:cordis acp ``` -这两条命令都需要 `DEEPSEEK_API_KEY`。工具、生命周期和安全约定由 [Cordis 工具参考](../../packages/self-modification/tool-cordis/README.md)定义。 +这两条命令都需要 `DEEPSEEK_API_KEY`。[Cordis 工具参考](../../packages/self-modification/tool-cordis/README.md)定义了四类约定:工具参数、存续时间、清理行为和安全性。 diff --git a/native/landlock-run/AGENTS.md b/native/landlock-run/AGENTS.md index 29bf66fa9d..48f1267c63 100644 --- a/native/landlock-run/AGENTS.md +++ b/native/landlock-run/AGENTS.md @@ -1,10 +1,10 @@ # AGENTS.md -This directory builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and speaks its CLI contract. It belongs to the repository's root pnpm workspace and lockfile. The main repository owns native CI, tarball assembly, verification, and npm publication; keep package-family changes coordinated with harness consumers in the same repository. +This directory builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and implements its CLI contract. It belongs to the repository's root pnpm workspace and lockfile. The main repository owns native CI, tarball assembly, verification, and npm publication; keep package-family changes coordinated with harness consumers in the same repository. ## Pre-release stance -The project is pre-1.0. Prefer the correct public shape over compatibility shims: if a package name, exported field, layout, or contract detail is wrong, rename it and update all references in the same change. Do not add deprecated aliases unless a stable release already needs them. +The project is pre-1.0. Prefer the correct public API over compatibility shims: if a package name, exported field, layout, or contract detail is wrong, rename it and update all references in the same change. Do not add deprecated aliases unless a stable release already needs them. ## Runtime safety rules @@ -47,4 +47,4 @@ pnpm test # entry tests everywhere; launcher tests need linux + built ## Documentation -User-facing docs are English. Keep the README focused on install, usage, and support status; durable design decisions belong in docs/ alongside the code, and the current implemented shape belongs in [docs/architecture.md](docs/architecture.md). +User-facing docs are English. Keep the README focused on install, usage, and support status; durable design decisions belong in docs/ alongside the code, and the current implementation belongs in [docs/architecture.md](docs/architecture.md). diff --git a/native/landlock-run/docs/architecture.md b/native/landlock-run/docs/architecture.md index 33c71d6893..72d2f85cff 100644 --- a/native/landlock-run/docs/architecture.md +++ b/native/landlock-run/docs/architecture.md @@ -9,7 +9,7 @@ The family is one entry package plus per-platform binary packages: - **Entry package** (`@deepseek-ai/node-addon-landlock-run`): ESM JavaScript. Owns the tool's CLI contract — path resolution (`launcherPath`), the functional probe (`probe`), grant-argv construction (`grantArgs`), and the contract constants. Ships the C source in its tarball for auditability. Lists every platform package as an `optionalDependency`. - **Platform packages** (`@deepseek-ai/node-addon-landlock-run-linux-{x64,arm64}`): one prebuilt static binary under `bin/`, a `prebuilds.json` declaring it, and no JavaScript at all. npm's `os`/`cpu` fields select the matching one at install time; the entry package resolves it to a file path — there is nothing to import. -Because the contract parser and the binary version together in one family, probe-parsing drift against the binary is structurally impossible — the failure mode the split exists to prevent. +Because the CLI parser and binary are versioned together in one package family, the parser cannot fall behind that binary version. Preventing that mismatch is why the package split exists. There is no shared loader package: platform packages have nothing to load. If a second tool ever needs shared JS, extract it then, not preemptively. diff --git a/native/landlock-run/docs/packaging.md b/native/landlock-run/docs/packaging.md index ec459eb655..16ee74de97 100644 --- a/native/landlock-run/docs/packaging.md +++ b/native/landlock-run/docs/packaging.md @@ -1,6 +1,6 @@ # Packaging -The package family uses the same broad shape as native packages such as esbuild: one JS entry package plus platform optional packages. Unlike Node addons there is no ABI or backend dimension — each platform package carries exactly the static executables its `prebuilds.json` declares. +The package family uses the same layout as native packages such as esbuild: one JS entry package plus platform optional packages. Unlike Node addons there is no ABI or backend division — each platform package carries exactly the static executables its `prebuilds.json` declares. ## Published packages diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 6cff1b6327..8f669e253a 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -2,17 +2,17 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md#conventions). -- **Plugin export shape:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). +- **Plugin exports:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **Optional services use `ctx.get(name)`.** Reserve `ctx.<name>` for declared injections; the property proxy is topology-sensitive, while strict `ctx.get` reads the global service store ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). -- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md). +- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external services or nondeterministic inputs and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md). - **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). -- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence. -- **Shape Service Definitions around all current Consumers.** Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`). +- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement point; otherwise fold it while preserving rollback, callback containment, and quiescence. +- **Design Service Definitions for all current Consumers.** Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`). - **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service. - **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice. - **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage. -- **Enforce at the operation boundary that owns the decision.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor. -- **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. +- **Enforce a decision in the operation that makes it.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor. +- **Publish state only at its commit point.** Emit each notification and update derived state only after the operation succeeds; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal** through the HMR-safety test required by [testing policy](../docs/testing.md): dispose the fiber and observe removal. - **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give empty installers package-specific `No runtime invariant:` reasons. Generated companions, unexplained empties, and ignored reporters fail [`verify-package-invariants`](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md). diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 240a707c65..dc299f49c3 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -125,7 +125,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { proc = this.startArgv(spec, confined.argv) } catch (error) { // LocalSubprocessService reports ENOENT/EACCES with the failed executable path through async - // `done` rejection; this covers alternatives that throw that shape synchronously. + // `done` rejection; this covers alternatives that throw the same error synchronously. if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { throw new SandboxUnavailableError(mode, String(error)) } diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 5d51b559d4..cec63092de 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 49c75bac1b6335459cedeb6c2c6c3435d444dbb0 -README.zh.md: 93adc52c11c375849cdcbf3ad7e199ef89fc384c +README.md: be03bceb39935fafb7acc7d3a99c1fe3af686f94 +README.zh.md: 10165486712fc078cdf1f4147522397a15c88955 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 49c75bac1b..be03bceb39 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -14,12 +14,12 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`ds | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | -| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape; a missing file also throws, because the caller named it | +| `loadOverlayPatches(binName, file)` | Parse a required top-level YAML array containing the same include `PatchOptions` entries described above; a missing file also throws because the caller named it | | `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR | | `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error | -| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of rows from the same file and patch layers is preceded by a `# ==` comment naming them, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | +| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline with the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts, and render YAML with `!!js` expressions verbatim; each run of rows that shares one source file and the same patch layers is preceded by a `# ==` comment naming that file and those layers, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), and read, parse, or field validation failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | @@ -57,4 +57,4 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec - **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. - **Environment discovery is launch-scoped** — `loadLayeredEnv` reads only the invocation directory and Harness home once; it does not search parents or follow a workspace selected later. `loadEnv` remains the one-directory helper for non-product bins. -- **User patch layers are patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a profile override restates the bundle fields it keeps. +- **A user patch replaces the whole matched config** — an id-targeted patch does not deep-merge, so a profile override restates the bundle fields it keeps. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 93adc52c11..1016548671 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -14,12 +14,12 @@ | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | -| `loadOverlayPatches(binName, file)` | 解析一份形状相同的必需 patch 列表文件;文件缺失同样抛出异常,因为该文件是调用方指名的 | +| `loadOverlayPatches(binName, file)` | 解析必需的顶层 YAML 数组,其中包含与上文相同的 include `PatchOptions` 条目;文件缺失也会抛出异常,因为该文件是调用方指名的 | | `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 | | `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步清理函数 | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | -| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来自同一文件且经相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | +| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`)离线合成基础配置与带标签的覆盖层,使结果与 `boot()` 挂载的内容一致,再渲染为 YAML,并原样保留 `!!js` 表达式;每段来源于同一文件且由相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取、解析或字段验证失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | @@ -57,4 +57,4 @@ profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [` - **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生辅助组件;没有该辅助组件的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 - **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 - **环境发现以启动为界**:`loadLayeredEnv` 只读取一次调用目录与 Harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。 -- **用户 patch 层采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。 +- **用户 patch 会替换匹配到的整个配置**:按 id 定位的 patch 不做深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 256ea34299..fa23e6f8da 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -306,7 +306,7 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ /** * Parse one loader patch list: a top-level YAML array of * `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and - * `insert` lists, `!!js` expressions allowed). Every shape failure throws, + * `insert` lists, `!!js` expressions allowed). Every invalid field or value throws, * because a patch file that cannot be applied at all is a misconfiguration; a * single patch whose target row is absent stays a per-entry Loader warning, so * one overlay shared across surfaces does not have to match every tree. @@ -397,12 +397,12 @@ export function renderConfigDump( throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`) } const baseLabel = basename(absoluteConfigPath) - // The YAML boundary yields untyped rows; the include validates entry shape + // YAML parsing yields untyped rows; the include validates each entry // at mount, and the dump prints whatever the file holds, so `EntryOptions` // here is structural trust in the same file `boot()` would include. const base = parsed as Parameters<typeof applyEntryPatches>[0] - // snapshot_k = ONE application of layers 1..k flattened — boot's exact call - // shape for that prefix. snapshot_N is therefore the mounted composition. + // snapshot_k = ONE application of layers 1..k flattened, using the exact + // arguments boot passes for that prefix. snapshot_N is the mounted composition. // The patches are cloned per call: applyEntryPatches detaches the entry // list but pushes `insert` rows by reference from the patch list, so // sharing patch objects across snapshot calls would leak a later diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index d951a87cf0..de44d58882 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -267,7 +267,7 @@ export function readProfileManifest(binName: string, dir: string): ProfileManife } catch (error) { throw new Error(`${binName}: failed to read profile manifest ${path}: ${String(error)}`) } - // File boundary: the shape check below validates what the parse type asserts. + // The field checks below validate the file data before trusting the parse type. const parsed = JSON.parse(raw) as ProfileManifest | null if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`${binName}: profile manifest ${path} must hold a JSON object`) diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 395b8b2cf6..f8f5e10856 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -14,24 +14,24 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07- 4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.) 5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription. 6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path). -7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for. +7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact uses the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). The plugin may use only the dependencies named by its `inject` declaration; there is no wider ctx to reach for. ## Reactive read and contract-currency discipline -How live data reaches render code, and what may cross a business boundary: +How live data reaches render code, and what UI domains may share: 1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes. 2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`. 3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework extension point and needs main-thread arbitration. -4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are exceptions pending migration to slots). +4. **UI domains share only JSON-compatible data and callbacks.** Owner props, injected values, store state, and provide contributions are plain serializable data or callbacks over such data. The injected `hooks` compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` fields remain until they move to slots). 5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves). 6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering. ## Export discipline (client plugin packages) -The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments): +The `/client` entrypoint of a UI plugin package is its public browser API, not a convenience barrel. Three rules apply package-wide (do not restate them as per-file comments): -1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Types are the extra allowance: contract types (owner shares, injected shapes, composed props aliases) export freely. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer. +1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Shared types (owner data, injected values, composed prop aliases) may also be exported. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer. 2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public surface to make a test compile. 3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself. @@ -44,7 +44,7 @@ The `/client` surface of a UI plugin package is a contract face, not a convenien The stack has one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md): 1. **Data object layer** (`runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine), and the snapshot-store engine (zustand/immer, `defineStore`, `shallowEqual`) lives here too — store products are bare observable sources with no hook members. Zero React imports — grep-assertable. -2. **Render machinery** (`web-react`, shell-only glue): the whole ctx↔React boundary — slot renderer/outlets, `SessionProvider`, the uSES bridge. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all. +2. **Render machinery** (`web-react`, shell-only glue): all ctx-to-React integration — slot renderer/outlets, `SessionProvider`, and the uSES adapter. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all. 3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares. Non-negotiables across the layers: @@ -62,7 +62,7 @@ Non-negotiables across the layers: ## Directory regime (plugin packages) -One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects. +One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits where its code could later become separate packages — ui-conversation is the example: `contract/` (the only shared API), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects. ## Styling @@ -99,9 +99,9 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a com ## New component checklist -1. Compose through register: merge the slot contract into `SlotMap`, declare the slot in its parent entry's `children`, register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists. +1. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists. 2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local. -3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery. +3. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery. 4. Tokens only in CSS; Chinese product copy; English comments. 5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`. 6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend. diff --git a/packages/client/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml index f9ce3024f1..da3e6eed6d 100644 --- a/packages/client/hmr/README.i18n.yaml +++ b/packages/client/hmr/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/hmr/README.md -README.md: 454c03cc3cd11722943efd025d164d9ca8233d25 +README.md: 9228292547376d3fbb0ea5ce56b9e0a35ced17b2 README.zh.md: ea62600911458556a3dcc7c46854e97db751c3ef diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index 454c03cc3c..9228292547 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -18,4 +18,4 @@ None; this package neither assembles nor sends a provider request. - **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out. - **No failure rollback** — a reload that fails leaves the entry FAILED and visible in the loader status projection; the previous bundle is not restored automatically. -- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; reconnect is the only refresh boundary. +- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; only reconnect refreshes it. diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 86d87df60f..c2c8d4e942 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/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/modules/README.md -README.md: 1d327c7252f4b3001ad758b7a4db01e9907c3060 -README.zh.md: a97672b909c98367e8c1287e3b341fb612f2d110 +README.md: 7b4c9b72e782dbdbb69d711ae7e022771afebace +README.zh.md: 6420f6324f38979af5428a9ad428f33525009f1f diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index 1d327c7252..7b4c9b72e7 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -20,5 +20,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (loadCache/edges/invalidate) is shaped for a general module graph so the externalization granularity can change without an interface change. +- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change. - **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record. diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index a97672b909..6420f6324f 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -20,5 +20,5 @@ Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包, ## 已知限制与暂缓事项 -- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表中的叶节点;接口(loadCache/edges/invalidate)按通用模块图塑形,因此可以改变 externalization 粒度而不更改接口。 +- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。 - **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id。 diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index 82070f9206..d53be39e0d 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -43,7 +43,7 @@ declare module 'cordis' { } } -/** package.json `dshClient` declaration shape (file boundary — validated field by field). */ +/** package.json `dshClient` declaration fields, validated one by one after reading the file. */ interface DshClientDeclaration { inject?: string[] platform: string @@ -138,7 +138,7 @@ function clientExportOf(pkgName: string, exportsField: unknown): string | undefi const fallback = (client as Record<string, unknown>).default if (typeof fallback === 'string') return fallback } - throw new Error(`client-modules: ${pkgName} exports["./client"] has an unsupported shape`) + throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`) } /** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */ diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 29b5a5f3b6..d7252cd195 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -58,7 +58,7 @@ export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME const REPOSITORY_ROOT = fileURLToPath(new URL('../..', import.meta.url)) -/** Rebase a physical lib-relative source onto the browser's repository-shaped URL tree. */ +/** Rebase a physical lib-relative source onto a browser URL that mirrors the repository directories. */ function browserSourcePath(source: string, sourcemapPath: string): string { if (!source.startsWith('.')) return source const physicalSource = resolvePath(dirname(sourcemapPath), source) @@ -71,7 +71,7 @@ function browserSourcePath(source: string, sourcemapPath: string): string { * plus the browser client bundle. Client packages emit both halves during the * Client pass by default; packages needed for Host reflection may opt into the * earlier Host pass. A package-level tsdown.config.ts REPLACES the root - * workspace shape, so the lib half must be restated here — dropping it leaves + * workspace layout, so the lib half must be restated here — dropping it leaves * the package without lib/index.js and the host Loader cannot import its node * half. * @param id - plugin id (package name), stamped into the __ModuleLoader__.load @@ -253,8 +253,8 @@ function clientConfig(id: string, entry: string): UserConfig { outputOptions: { entryFileNames: 'client.js', // The map is served from /plugins/<scoped-package>/client.js.map. The - // browser resolves its local sources back into the repository-shaped - // /packages/<group>/<package>/src tree; sourcesContent keeps them usable + // browser resolves its local sources back into URLs that mirror the + // /packages/<group>/<package>/src directories; sourcesContent keeps them usable // without exposing that tree as an HTTP route. sourcemapPathTransform: browserSourcePath, banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`, diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index acf611bbca..58d54302ad 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/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-command/README.md -README.md: db785e769cb40235a77d05b4b66d096896a35d8a -README.zh.md: f0f23319a8919a0dee715e9da03ab064b6e3298a +README.md: e49ce89804886a11f102fcaf60316e8044965c10 +README.zh.md: 8bd5afd7d0a173980f476cb96f8115525602b0b4 diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index db785e769c..e49ce89804 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Client command surface (`ctx.command`): the session-keyed command-directory cache, the `/` command source with matchSpace/matchEnter adjudication hooks, three-kind dispatch (execute / popupSelect / leadingInput), and the popupSelect registration face for business packages. Contract: the [web command surfaces Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md). +Client command API (`ctx.command`): the session-keyed command-directory cache, the `/` command source with `matchSpace`/`matchEnter` decision hooks, three-kind dispatch (`execute` / `popupSelect` / `leadingInput`), and popupSelect registration for business packages. The [web command Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md) records the decision. -`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute. +`src/client/contract.ts` is the fixed business contract: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-contained — the shell component belongs to this package and business packages never see it. A contribution is a client-owned command (a host-name collision fails loud); a decoration adds a bare-invocation popup to an EXISTING host command. The host keeps its catalog row, argument claim (space / argued Enter), and lifecycle logging, and a decorated name with no host row in the session's directory never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect`, and everything else is `execute`. `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies) and by `session/preset-changed` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. @@ -12,7 +12,7 @@ Menu queries fuzzy-match ordered, case-insensitive subsequences of command names `PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`. -The `/client` export surface is the plugin body (`apply`/`inject`), `CommandService`, the directory and popup classes with their state types, and the frozen contract types; the shell component itself is internal to the overlay registration. +The `/client` entrypoint exports the plugin body (`apply`/`inject`), `CommandService`, the directory and popup classes with their state types, and the fixed contract types; the shell component itself is internal to the overlay registration. ## Model Experience diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index f0f23319a8..8bd5afd7d0 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpace/matchEnter 裁决钩子的 `/` 命令 source、三型派发(execute/popupSelect/leadingInput),以及面向业务包的 popupSelect 注册面。约定:[Web 命令业务面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。 +客户端命令 API(`ctx.command`):以会话为 key 的命令目录缓存、带 `matchSpace`/`matchEnter` 决策钩子的 `/` 命令 source、三类派发(`execute`/`popupSelect`/`leadingInput`),以及面向业务包的 popupSelect 注册。[Web 命令 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)记录了这项决策。 -`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claim(space / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。 +`src/client/contract.ts` 是固定的业务 API 约定:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 自己提供 popup 数据——外层组件归本包所有,业务包永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则为**已存在的** host 命令添加裸调用 popup。host 保留目录行、带参 claim(space / 带参 Enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行,则永不触发。命令类型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect`,其余全部是 `execute`。 `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),也由 `session/preset-changed` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 @@ -12,7 +12,7 @@ `PopupSelectController`(`src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`(SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边)。壳是打开期间持有焦点的瞬态层;onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS,回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。 -`/client` 导出表层是插件主体(`apply`/`inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及冻结的约定类型;壳组件本身是 overlay 注册的内部实现。 +`/client` 入口导出插件主体(`apply`/`inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及固定的约定类型;外层组件本身是 overlay 注册的内部实现。 ## 模型体验 diff --git a/packages/client/ui-command/src/client/directory.ts b/packages/client/ui-command/src/client/directory.ts index a7cdca7fd8..0e4fd916b2 100644 --- a/packages/client/ui-command/src/client/directory.ts +++ b/packages/client/ui-command/src/client/directory.ts @@ -1,7 +1,7 @@ /** * Command-directory cache keyed by session: one entry per served catalog — * every session is agent-backed, so `command.list({sessionId})` is the only - * address shape. Each entry keeps the single-flight / soft-hard invalidation + * request fields. Each entry keeps the single-flight / soft-hard invalidation * / epoch-guard behavior of the original global cache; the session-key axis * is the only extra dimension. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 13edd00c37..6ebee98c95 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -516,7 +516,7 @@ export function InputBar({ } pushPlain(draft.length) if (deco.hint !== null) { - // Claim tokens are shaped `/name ` (trailing space); trim to the bare name. + // Claim tokens have the `/name ` format (trailing space); trim to the bare name. const commandName = input?.claim?.token.slice(1).trim() ?? '' const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}` // Dynamic lookup by claimed command name: unknown commands miss the diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 2c041e0eae..d9b185b8fd 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -38,7 +38,7 @@ const NS = 'goal' /** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale'] -/** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */ +/** Map one generated Remote call, including synchronous namespace lookup failures, to the fields rendered by the goal strip. */ async function settle(invoke: () => Promise<unknown>): Promise<GoalActionResult> { try { await invoke() diff --git a/packages/client/ui-model/src/client/service.ts b/packages/client/ui-model/src/client/service.ts index c2a8741464..5bf2c744a3 100644 --- a/packages/client/ui-model/src/client/service.ts +++ b/packages/client/ui-model/src/client/service.ts @@ -7,7 +7,7 @@ * Per-session storage follows the client service pattern (SlashService / * CommandService): a lazy service-internal map whose entry is deleted by the * owning scope's disposer. The host `dsh-scope` ScopedLayers registry does - * not transplant here: it derives scope from the host carrier mechanism + * does not belong here: it derives scope from the host carrier mechanism * (object-keyed), while client scopes tag contexts with branded SessionId * strings, and it models global+shadow named registries — this is a * per-session singleton with no global layer to merge. diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index ec20685def..c03e091a5e 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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-models/README.md -README.md: 9841ced87ae345c685c59e96a7b9088d474181f5 -README.zh.md: bb1445fbc8093d356ce838948b8338fa04919063 +README.md: e0c5728d47e053df1934ef9eb69df3f8d985a4ec +README.zh.md: fe11e6cdd190e19d5b5dac6dc95950ba59a3172b diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 9841ced87a..e0c5728d47 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model list and endpoint interrogation @@ -31,5 +31,5 @@ None; this package neither assembles nor sends a provider request. - **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. - **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `<ROUTE>_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them. - **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create. -- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that listing shape, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand. +- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that model-list response format, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand. - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index bb1445fbc8..fe11e6cdd1 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -8,7 +8,7 @@ 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型列表与端点询问 @@ -31,5 +31,5 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型, - **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 - **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据和无法识别的目标会保留,因为该行无法证明自己拥有它们。 - **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。 -- **询问只覆盖 OpenAI 兼容端点**:适配器只读这一种列表形状,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。 +- **询问只覆盖 OpenAI 兼容端点**:适配器只读这种模型列表响应格式,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 50cbad2549..16fb544dc0 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -196,7 +196,7 @@ let loadCount = 0 * Subscribe to lazy-grammar load completions; `listener` fires after a * {@link LAZY_GRAMMARS} grammar finishes registering on the singleton, so a * caller that rendered its plain fallback while the grammar loaded can - * re-highlight. Shaped as a `useSyncExternalStore` subscribe: pair it with + * re-highlight. Uses the `useSyncExternalStore` subscribe signature; pair it with * {@link grammarLoadCount} as the snapshot. Returns an unsubscribe function. * @param listener - invoked (no args) on each grammar-load completion. * @returns a disposer that removes the listener. diff --git a/packages/client/ui-question/src/client/contract/slots.ts b/packages/client/ui-question/src/client/contract/slots.ts index 8f9d9f8c75..e8bada18b0 100644 --- a/packages/client/ui-question/src/client/contract/slots.ts +++ b/packages/client/ui-question/src/client/contract/slots.ts @@ -86,8 +86,8 @@ export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | u /** * Question domain face over the carrier: render identity and questions - * transparently forwarded; answer/cancel own the wire encoding (the ok value - * shape and the cancelled error) and turn a rejected carrier receipt into a + * transparently forwarded; answer/cancel own the wire encoding (the success + * fields and the cancelled error) and turn a rejected carrier receipt into a * thrown error. Components mint one per carrier via useMemo (never inside a * select — a per-dispatch mint would churn identity and break memoization). */ diff --git a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts index 08d6389686..1c9393e6f4 100644 --- a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts @@ -77,9 +77,9 @@ export interface SearchCardModel { /** * Whether every file group in a matches view is structurally valid: the wire * frame carries `shape` and `card` as strings the host schema checks, but not the - * grouped shape, so a version mismatch or loose producer could deliver + * grouped `files` fields, so a version mismatch or loose producer could deliver * `shape: 'matches'` with a missing or malformed `files`. Rendering that would - * crash {@link SearchBlock} at `.reduce`/`.map`; an invalid shape falls to the + * crash {@link SearchBlock} at `.reduce`/`.map`; invalid fields select the * generic path instead. * @param files - the candidate `files` field off the untrusted result view. * @returns whether `files` is a valid {@link SearchFileGroup} array. @@ -136,12 +136,13 @@ export function searchCardModel(block: ToolCallBlock): SearchCardModel | null { // The recovery footer only matters when the tool capped the result: an // uncapped card holds every match/path, so the raw text adds nothing the card // does not already show. When capped, the raw result's `Full … stored at …` - // locator is the only path to the dropped rows, so surface it. + // locator is the only way to retrieve the omitted rows, so include it. const recovery = result.truncated ? flattenContent(block.content) : undefined if (result.shape === 'matches') { // `files` rides the untrusted wire frame: the host schema checks `card`/`shape` - // strings but not the grouped shape, so validate it before SearchBlock, which - // would crash on a missing/malformed `files`. An invalid shape falls to generic. + // strings but not the grouped `files` fields, so validate them before + // SearchBlock, which would crash on a missing or malformed `files`. + // Invalid fields select the generic view. if (!isValidFiles(result.files)) return null return { title: result.title, recovery, card: { kind: 'matches', files: result.files, ...common } } } diff --git a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx index 8f45bf3a49..880938567f 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx @@ -21,8 +21,8 @@ function isAnswer(value: unknown): value is AnswerEntry { return typeof value === 'object' && value !== null } -/** Answered-count summary off the result JSON (a skipped question has - * empty `selected` and no `custom`); null on unexpected shape (generic fallback). */ +/** Answered-count summary from the result JSON (a skipped question has + * empty `selected` and no `custom`); null when answer fields are invalid. */ function answeredSummary(text: string, t: AskQuestionRowProps['t']): string | null { let parsed: unknown try { diff --git a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx index 2556cfd8b4..111189aede 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx @@ -41,7 +41,7 @@ function summarize(argsRaw: string, t: TodoRowProps['t']): RowSummary | null { // Mid-stream truncation or malformed model JSON: fall back to the generic summary. return null } - // Valid JSON with an invalid shape (null root, non-array todos, null items — + // Valid JSON with invalid todo fields (null root, non-array todos, null items — // a rejected tool/call retains such args verbatim): same generic fallback. if (typeof parsed !== 'object' || parsed === null) return null const todos = (parsed as { todos?: unknown }).todos diff --git a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts index cbe0d0ac55..67a8419bd3 100644 --- a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts +++ b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts @@ -234,7 +234,7 @@ export interface PendingCall { reject(error: Error): void } -/** Constructor shape for one program-visible binding rejection class. */ +/** Constructor type for one program-visible binding rejection class. */ export type BindingErrorConstructor = new (memberName: string, message: string) => Error /** diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index ec1fa015ed..ffd3fd22a8 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -32,13 +32,13 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa case 'assistant/message': case 'tool/call': case 'tool/result': - fail('time-context reading must be appended at a prompt boundary') + fail('time-context reading must be appended during prompt assembly') break default: break } } - fail('time-context reading must be appended at a prompt boundary') + fail('time-context reading must be appended during prompt assembly') } /** Validate one plugin-attributed time reading against its session position and timestamp. */ diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index 59ffa66a89..b5f0385b3f 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -129,20 +129,20 @@ describe('time-context invariants', () => { const session = preparing(1, 2) session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }) expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) }) - .toThrow(/at a prompt boundary/) + .toThrow(/during prompt assembly/) }) - it('rejects a reading outside a prompt boundary', async () => { + it('rejects a reading outside prompt assembly', async () => { const ctx = await setup() const ended = preparing(1, 1) ended.append('step/end', { turn: 1, step: 1 }) - expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/at a prompt boundary/) + expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/during prompt assembly/) const notEntered = Session.create(SessionId('time-invariant-turn-only')) notEntered.append('turn/start', { turn: 1 }) - expect(() => { ctx.emit('session/event', notEntered, event(reading())) }).toThrow(/at a prompt boundary/) + expect(() => { ctx.emit('session/event', notEntered, event(reading())) }).toThrow(/during prompt assembly/) expect(() => { ctx.emit('session/event', Session.create(SessionId('time-invariant-empty')), event(reading())) - }).toThrow(/at a prompt boundary/) + }).toThrow(/during prompt assembly/) }) it.each([ diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index df32eaf80c..f27b2f4622 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -503,8 +503,8 @@ export class Session { /** * Restore a detached session by taking ownership of fresh persistence values. - * Storage shape, event envelopes, sequence continuity, surface transitions, - * and header fields are validated before the graphs are frozen in place. + * The storage format, event envelopes, sequence continuity, surface transitions, + * and header fields are validated before the restored objects are frozen. * @param id - restored session identity. * @param seed - fresh detached events whose ownership is transferred. * @param header - fresh detached metadata whose ownership is transferred. diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 6f1757ff28..23b5936e08 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -184,7 +184,7 @@ export interface Config { persona?: string /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. - * Shape errors fail at load and unknown names fail at assembly; known names + * Invalid fields fail at load and unknown names fail at assembly; known names * hidden in one scope may be absent there. Omitted means lexicographic order. */ toolOrder?: string[] diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 38a9c2ede3..0c5e1fee44 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: f3d1b4741c7fde64669794d079c36a18e633c0c1 -README.zh.md: d3054372095ef0cfdabc6cf80e0faa41a3b12d4c +README.md: 2c9833c3505c765283559590c8bc28b3c2077e2e +README.zh.md: d7766b432c5a319d214da80e3df438489519be92 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index f3d1b4741c..2c9833c350 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -36,7 +36,7 @@ Cancellation is cooperative and quiescent. Every typed invocation supplies a cal ### Live events -The live registry pipeline has three transformable waterfalls, then the definition-owned content finalizer, then the observe-only `tools/result` boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated region of [tools.md](../../../docs/subsystems/tools.md#cordis-surface), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards. +The live registry pipeline has three transformable waterfalls, then the definition-owned content finalizer, then the observe-only `tools/result` event; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure containment contracts live in the generated region of [tools.md](../../../docs/subsystems/tools.md#cordis-surface), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards. ### Key types @@ -120,7 +120,7 @@ Under `code` or `both`, the registry exposes the reserved `run_code` transport a - **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute, or whenever it is called outside the SDK render, which supplies the naming context a `TypedDict` declaration needs). - **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `<parent>:code:<n>`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails. - **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. -- **Result boundary**: intermediate binding values cross the worker boundary whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that ledger. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill. +- **Result size**: intermediate binding values cross the worker process whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that limit. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill. ### Parallel execution @@ -146,7 +146,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat #### What the model sees -Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode surface. The instructions and SDK block match the loaded runtime's language; the TypeScript flavor (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python flavor (for any runtime reporting `language: 'python'`) is the same shape with Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`). +Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode API. The instructions and SDK block match the loaded runtime's language; the TypeScript version (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python version (for any runtime reporting `language: 'python'`) has the same operations and types in Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`). ##### Code Mode SDK instructions diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index d305437209..d7766b432c 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -36,7 +36,7 @@ tools: ### 实时事件 -实时注册表流水线先经过 3 个可变换的 waterfall,再经过由定义拥有的内容终结器,最后到达仅观测的 `tools/result` 边界;注册表变更有意作为不过滤的共享状态通知。确切签名、分发 mode、作用域筛选和故障收容约定位于 [tools.md](../../../docs/subsystems/tools.md#cordis-surface) 的生成区块,完整顺序则在生成的[工具执行流水线](../../../docs/tool-execution-pipeline.md)中可视化。`tools/result` 是实时事件;名称相近的 `tool/result` 是 agent loop 随后追加的持久会话事件。 +实时注册表流水线先经过 3 个可变换的 waterfall,再经过由定义拥有的内容终结器,最后发布仅供观测的 `tools/result` 事件;注册表变更有意作为不过滤的共享状态通知。确切签名、分发 mode、作用域筛选和失败隔离约定位于 [tools.md](../../../docs/subsystems/tools.md#cordis-surface) 的生成区块,完整顺序则在生成的[工具执行流水线](../../../docs/tool-execution-pipeline.md)中可视化。`tools/result` 是实时事件;名称相近的 `tool/result` 是 agent loop 随后追加的持久会话事件。 ### 关键类型 @@ -120,7 +120,7 @@ ctx.tools.register(defineTool({ - **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态发出 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明;Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。两个代码生成器都已导出,且绝不会在提示词组装期间抛出:`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`;`jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,或在 SDK 渲染之外被调用时——`TypedDict` 声明所需的命名上下文由该渲染提供——整个对象降级为 `dict[str, Any]`)。 - **分发桥接层**(`run_code` 的 execute):每个绑定调用都会在分发前快照为无损 JSON(`undefined`、`BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发约定的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10;设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker,并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `<parent>:code:<n>`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。 - **结算纪律**:桥接层拥有一个运行作用域的中止机制;该中止会跟随传入的外层信号,并在运行因任何原因结算时触发,因此预算耗尽会中止正在运行的子工具,而不会将其遗留。桥接层随后会在返回之前排空队列,使每个 `tool/code-dispatch` 都落在仍打开的轮次内。失败的运行会抛出 `CodeRunFailedError`(`code: 'CODE_RUN_FAILED'`,message = 失败类型 + 已捕获日志),流水线会将其转换为模型可据以自我修正的结构化 `isError`。 -- **结果边界**:中间绑定值会完整跨越 worker 边界,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB)只应用于组合序列化后的外层日志数组、完成值或失败消息载荷;固定的结果 envelope 语法和呈现空白不计入该账本。无效和超限的完成会明确失败,只有此外层结果可以使用普通 spill。 +- **结果大小**:中间绑定值会完整传入 worker 进程,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB)只应用于组合序列化后的外层日志数组、完成值或失败消息载荷;固定的结果 envelope 语法和呈现空白不计入该上限。无效和超限的完成会明确失败,只有此外层结果可以使用普通 spill。 ### 并行执行 @@ -146,7 +146,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e #### 模型看到的内容 -Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode 接口。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 风格(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 风格(用于任何报告 `language: 'python'` 的运行时)形状相同,只是换成 Python 语法(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。 +Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode API。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 版本(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 版本(用于任何报告 `language: 'python'` 的运行时)以 Python 语法提供相同操作和类型(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。 ##### Code Mode SDK 说明 diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 4f094e4ade..e7c13b2168 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -28,7 +28,7 @@ export const SDK_SECTION_ORDER = 150 * strings share one source of truth. Keyed by `CodeRuntime.language`, mirroring * `SDK_RENDERERS` in {@link ./index.ts}. The emitted flavor MUST match the * semantics the same language's SDK instructions promise, so the model never - * receives a TypeScript-shaped schema beside a Python SDK (or vice versa). + * receives a TypeScript schema beside a Python SDK (or vice versa). */ interface RunCodeFlavor { /** The tool `description` the model sees for this language. */ @@ -338,8 +338,8 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge exec.signal.addEventListener('abort', onOuterAbort, { once: true }) let dispatches = 0 - // The per-run scheduler, reusing the NATIVE concurrency contract through - // the registry's staged view (the loop scheduler's own boundary) — and the + // The per-run scheduler uses the registry's staged interface and follows + // the same concurrency rules as the native loop. It also follows the // native loop's SEQUENCING: every ordered stage (the dispatch-start // append, prepare = pre-execute/guards, finalize/finish = post-execute, // context deferral, the settle append) runs inside ONE driver lane, so @@ -369,7 +369,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge } const pendingQueue: PendingDispatch[] = [] const inFlight = new Set<Promise<void>>() - /** Tracked settle-event side work (log shaping + append), drained at run settlement. */ + /** Tracked settle-event side work (log-content listener + append), drained at run settlement. */ const logWork = new Set<Promise<void>>() const commitQueue: PendingDispatch[] = [] let exclusiveActive = false @@ -394,7 +394,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge driverRun = (async () => { try { for (;;) { - // Arm before inspecting state so a settle or submission landing + // Create the wakeup promise before inspecting state so a settle or submission arriving // between the checks and the await below cannot be lost. const signal = new Promise<void>((resolve) => { wake = resolve }) const commitHead = commitQueue[0] @@ -449,7 +449,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge // entries, awaits the live pool, and drains the ordered commit lane — // including a commit already in progress when the program returned. await drive() - // Every settle's shaped append lands inside the open run_code turn + // Every settle event is appended inside the open run_code turn // (tasks self-remove on settlement). while (logWork.size > 0) await Promise.allSettled([...logWork]) } @@ -483,10 +483,10 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge | { kind: 'post-result' | 'final-result'; exec: ToolRunContext; result: ToolExecutionResult } | undefined const settle = (result: ToolExecutionResult): void => { - // The program gets its value NOW: log shaping (e.g. a spill - // backend) must never delay the binding or occupy a dispatch - // slot. The shaped append is tracked side work; the run's - // settlement drains logWork so every settle event still lands + // The program gets its value NOW: the log-content listener (for + // example, a spill backend) must never delay the binding or occupy + // a dispatch slot. The event append is tracked side work; the run's + // settlement drains logWork so every settle event is still appended // inside the open turn (shapeDispatchLog is contained, so this // chain cannot reject). resolve(result.isError @@ -495,9 +495,9 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge const agent = exec.agent if (agent === undefined) return const task: Promise<void> = (async () => { - // The durable copy may be reshaped (e.g. spilled to a preview + - // locator) by the log-shaping waterfall; the program's value - // and the model contract are untouched. + // The listener may replace the durable copy with a preview and + // locator; the program's value and model-visible result are + // untouched. const logged = await shapeDispatchLog({ exec, agent, subCallId, name, isError: result.isError, // The registry deep-froze this projection at result @@ -560,16 +560,16 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge for (const context of result.additionalContexts ?? []) { exec.deferContext(context) } - // Like the context forwarding above, cross-boundary facts travel - // on the nested result and the composite forwards them: only a - // successful nested result can carry the terminal marker + // The composite forwards `additionalContexts` above and + // `concludesTurn` here from the nested result. Only a successful + // nested result can carry the terminal marker // (ToolExecutionFailure types it never), so a policy-converted // failure cannot stop the turn through a recovering program. if (result.concludesTurn) exec.concludeTurn() settle(result) - // Backpressure on the shaped-append side channel: pending log - // tasks (each retaining a full result while a slow backend - // stores it) are bounded by the pool cap — beyond it the + // Backpressure on pending event-append tasks: each task retains + // a full result while a slow backend stores it, so the pool cap + // bounds their count. Beyond the cap, the // ordered lane waits, so later sub-calls cannot start and // pending I/O/memory cannot grow without bound. while (logWork.size > maxParallel) await Promise.race(logWork) @@ -578,7 +578,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge wakeup() void drive() }) - // A budget expiry or outer cancel that lands while this call was in + // A budget expiry or outer cancel that occurs while this call was in // flight already aborted the dispatch; stop the program now rather // than hand it a result from a run that is over. if (runOver()) { @@ -661,7 +661,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge Object.defineProperty(definition, 'parameters', { enumerable: true, // Recompile through the same spec→schema projection defineTool used, so - // the emitted shape can never drift from the validated one. + // the emitted schema always matches the validated specification. get: () => parameterSchemaSpecToJsonSchema({ code: { type: 'string', required: true, description: resolveFlavor(peekRuntime).codeDescription }, description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION }, diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index a44df0863f..a1653e7d16 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -160,13 +160,14 @@ declare module 'cordis' { */ 'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision> /** - * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before - * the bridge appends its `tool/code-dispatch` event. `next()` keeps the + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/code-dispatch` event. `next()` keeps the * content unchanged; a listener may return replacement blocks (e.g. the * spill policy's preview + locator for an oversized text result). Only the * logged copy is affected — the program already received the complete * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the unshaped content. + * the bridge falls back to logging the original settled content. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. * @param dispatch - the parent execution, sub-call identity, and the settled content to log. * @mode waterfall @@ -1183,8 +1184,8 @@ export class ToolRegistry extends Service { /** * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch * and return the content the bridge should log on `tool/code-dispatch`. - * Contained: a throwing listener falls back to the unshaped content — log - * shaping must never fail the dispatch or lose the settle event. Private: + * Contained: when a listener throws, the method logs the original settled + * content; that failure must not fail the dispatch or omit the settle event. Private: * the ONE consumer is the `run_code` bridge this registry constructs, which * receives it as a capability parameter (the `requireRuntime` idiom) — the * waterfall, not this invoker, is the public extension point. @@ -1196,7 +1197,7 @@ export class ToolRegistry extends Service { () => Promise.resolve(dispatch.content), ) } catch (error: unknown) { - this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the unshaped content`) + this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the original settled content`) return dispatch.content } } diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts index 9b6ca88d93..9191bcfbfa 100644 --- a/packages/core/tools/src/json-schema.ts +++ b/packages/core/tools/src/json-schema.ts @@ -7,7 +7,7 @@ * * Unsupported or misplaced keywords reject rather than being accepted without * enforcement. Consumers that require an object root apply - * {@link assertObjectJsonSchema} at their own boundary. + * {@link assertObjectJsonSchema} before accepting input. * @module dsh-tools/json-schema */ @@ -25,7 +25,7 @@ type JsonSchemaScalarType = Exclude<JsonSchemaType, 'object' | 'array'> /** * One raw JSON Schema node in the enforced subset. The optional fields express - * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid + * the external wire schema; {@link assertSupportedJsonSchema} rejects invalid * combinations before a caller treats the node as trusted. */ export interface JsonSchemaNode { diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index e4b241b75f..4898ec80e1 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -733,7 +733,7 @@ export function jsonSchemaToPy(schema: unknown): string { /** The fixed model-facing usage contract rendered above the declarations. */ const SDK_INSTRUCTIONS = `## Writing code for run_code -Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). At run time exactly two of the names declared below are bound: \`tools\` and \`ToolCallError\`. Everything else is a STATIC STUB describing shapes — in particular the \`TypedDict\` classes do NOT exist at run time, so build arguments as plain \`dict\`/\`list\` JSON values: \`await tools.name({"field": 1})\`, never \`FooArgs(field=1)\`, which raises \`NameError\`. Inside the program: +Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). At run time exactly two of the names declared below are bound: \`tools\` and \`ToolCallError\`. Everything else is a STATIC STUB describing argument and return types — in particular the \`TypedDict\` classes do NOT exist at run time, so build arguments as plain \`dict\`/\`list\` JSON values: \`await tools.name({"field": 1})\`, never \`FooArgs(field=1)\`, which raises \`NameError\`. Inside the program: - Call tools as \`await tools.name(args)\` — subscript access for exotic, reserved, or underscore-leading names: \`await tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value (each method's return type below). Tool arguments must be lossless JSON. - A FAILED tool call raises \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose message is human-readable — wrap in \`try/except\` to handle and continue. diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 34758b840f..1794e7e36e 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -59,7 +59,7 @@ async function setup(options: SetupOptions = {}) { return { ctx, tools: ctx.tools, systemPrompt: ctx.systemPrompt, runtime: runtime! } } -/** Mint one production-shaped agent scope that can register scoped tool policy. */ +/** Mint an agent scope configured like production that can register scoped tool policy. */ async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: Scope; agent: Agent }> { const agent = { id: SessionId(name) } as Agent let scope!: Scope @@ -407,7 +407,7 @@ describe('mode-aware wire contribution', () => { }) it('degrades the run_code flavor to TypeScript when no runtime is mounted', async () => { - // Any reader of the definition without a mounted runtime lands here; the + // Any reader of the definition without a mounted runtime uses this fallback; the // shipped one is the tool-catalog generator, which boots the registry under // `mode: code` and reads run_code's schema WITHOUT a runtime. peekRuntime // returns undefined there, so the flavor getter degrades to the TS default @@ -663,7 +663,7 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { expect(stages).toEqual(['post-enter:writer', 'post-exit:writer']) }) - it('run settlement drains a commit already in progress: the settle event lands inside the turn', async () => { + it('run settlement drains a commit already in progress: the settle event is appended inside the turn', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const gated = registerGated(ctx, 'safe_read', true) const { agent, events } = fakeAgent() @@ -921,10 +921,10 @@ describe('the run_code dispatch bridge', () => { expect(result.content[0]).toEqual({ type: 'text', text: 'caught: deliberate failure' }) }) - it('a throwing tools/code-dispatch-log listener is contained: the unshaped content is logged', async () => { + it('a throwing tools/code-dispatch-log listener is contained: the original settled content is logged', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) registerEcho(ctx) - ctx.on('tools/code-dispatch-log', () => { throw new Error('shaper exploded') }) + ctx.on('tools/code-dispatch-log', () => { throw new Error('log-content listener failed') }) const { agent, events } = fakeAgent() runtime.behavior = async (request) => { const value = await request.bindings[0]!.functions.echo!({ value: 'x' }) @@ -1593,7 +1593,7 @@ describe('per-agent presentation', () => { const { ctx, systemPrompt } = await setup({ mode: 'native' }) registerEcho(ctx) // The preset's standing scope declares once; the agent only PARENTS to it - // (the per-preset standing-mount shape — no per-agent declaration at all). + // (the per-preset standing mount configuration has no per-agent declaration). const standing = await mintAgentScope(ctx, 'preset:code-like') standing.scope.ctx.tools.presentAs('code') const joined = await mintAgentScope(ctx, 'joined-agent') diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index cdb4bd6eb8..e7800253f3 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -344,7 +344,7 @@ export class CredentialsLocal extends Credentials { /* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same reviewed contract as settings-local, deliberately mirrored (prefer symmetry for parallel values); the two providers own different documents and - failure policies, so extracting the shape would couple their teardown + failure policies, so extracting a shared helper would couple their teardown semantics across packages for a handful of lines. */ /** Queue one exclusive document operation behind every earlier one. */ private enqueue<T>(operation: () => Promise<T>): Promise<T> { diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index d6f23acf04..f10fd8fb47 100644 --- a/packages/e2b/subprocess-e2b/README.i18n.yaml +++ b/packages/e2b/subprocess-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/subprocess-e2b/README.md -README.md: bb0ac1d5f1f3dbfd0f021d5fbebbab13dcee37ee -README.zh.md: d511df72284b047626ece628b702a2ac8d2f873d +README.md: e27582e5603e430e1467cb6e47c6f12bd1a0b886 +README.zh.md: 788c6273f1f2f30795d8d1ea09b481a85b88a2f6 diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index bb0ac1d5f1..e27582e560 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -38,6 +38,6 @@ No direct invalidation; the named consumers own any request-prefix changes. - **Control state shares the sandbox user's UID** — E2B runs every command as the same default user, so `0700`/`0600` modes cannot isolate `.dsh-e2b` control files from concurrently running sandbox processes. A background process could rewrite `pid`/`exit-code` or read a not-yet-consumed `environment` file. The adapter validates published values and refuses group ids whose negative form is unsafe to signal (`<= 1`), but real isolation needs an E2B per-command user or an out-of-band control channel. - **Numeric process identities are not reuse-fenced** — E2B exposes numeric PID/PGID PTY input, signalling, and cleanup operations but no atomic identity-bound alternative. The adapter minimizes host round trips and live coverage exercises the reproducible stale-interrupt overlap; replacement is deferred until E2B adds an identity primitive or a failure demonstrates a narrower protocol. - **The initial environment probe inherits sandbox defaults** — E2B merges command overrides with default environment entries, so the probe cannot blank unknown credential-shaped names before enumerating them. A same-UID untrusted process already in the sandbox could inspect that short-lived control shell; this POC therefore does not support secrets in sandbox-default environment variables and requires an E2B replacement-environment primitive to close the gap. -- **E2B exposes no signal fact** — an adapter-requested `SIGTERM` or `SIGKILL` is reported only when no wrapper-published direct exit code wins; every unrequested SDK exit remains an exit code, including values shaped like `128 + signal`. +- **E2B exposes no signal fact** — an adapter-requested `SIGTERM` or `SIGKILL` is reported only when no wrapper-published direct exit code wins; every unrequested SDK exit remains an exit code, including values equal to `128 + signal`. - **Exact terminal stdin-wait inspection is unavailable** — E2B exposes the foreground process group but not the syscall evidence needed to prove it is waiting on fd 0, so the generic PTY backend falls back to controlled prompt markers and bounded silence. - **Linux utility and E2B transport semantics are assumed** — there is no Windows, escaped-session recovery, or network-partition fidelity layer. diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index d511df7228..788c6273f1 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -38,6 +38,6 @@ E2B 默认基础镜像提供该适配器调用的运行时和 Bash/GNU 工具: - **控制状态与沙箱用户同 UID**:E2B 以同一默认用户运行每条命令,因此 `0700`/`0600` 权限无法把 `.dsh-e2b` 控制文件与并发运行的沙箱进程隔离开。后台进程可以改写 `pid`/`exit-code`,或读取尚未被消费的 `environment` 文件。适配器会验证已发布的值,并拒绝取负后不安全的进程组 ID(`<= 1`),但真正的隔离需要 E2B 提供按命令用户或带外控制通道。 - **数值进程身份没有复用围栏**:E2B 公开基于数值 PID/PGID 的 PTY 输入、信号发送和清理操作,却没有与身份原子绑定的替代方案。适配器会尽量减少宿主往返,真实环境测试会覆盖可复现的陈旧中断重叠;在 E2B 新增身份原语,或实际故障证明需要更窄的协议之前,替代方案会继续延后。 - **初始环境探测会继承沙箱默认值**:E2B 会把命令覆盖与默认环境条目合并,因此探测无法在枚举未知且形似凭据的名称之前将它们置空。一个已在沙箱内运行的同 UID 不可信进程可以检查该短时存在的控制 shell;因此,该 POC 不支持把 secret 放入沙箱默认环境变量,需要 E2B 的替换环境原语才能弥合该缺口。 -- **E2B 不公开信号事实**:适配器请求的 `SIGTERM` 或 `SIGKILL` 只有在包装层发布的直接退出码没有胜出时才报告为信号;其他未请求的 SDK 退出始终保留为退出码,包括形似 `128 + signal` 的值。 +- **E2B 不公开信号事实**:适配器请求的 `SIGTERM` 或 `SIGKILL` 只有在包装层发布的直接退出码没有胜出时才报告为信号;其他未请求的 SDK 退出始终保留为退出码,包括等于 `128 + signal` 的值。 - **无法精确检查终端 stdin 等待状态**:E2B 会公开前台进程组,但不提供证明其正在等待 fd 0 所需的 syscall 证据,因此通用 PTY 后端会回退到受控提示符标记与有界静默机制。 - **依赖 Linux 工具与 E2B 传输语义**:没有 Windows、逃逸会话恢复或网络分区的保真层。 diff --git a/packages/experimental/AGENTS.md b/packages/experimental/AGENTS.md index e9cb3d2b51..ee6bf61598 100644 --- a/packages/experimental/AGENTS.md +++ b/packages/experimental/AGENTS.md @@ -2,10 +2,10 @@ These rules supplement the [package rules](../AGENTS.md). The [experimental and internal package group decision](../../.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md) owns the rationale. -- All Cordis plugin packages whose whole public contract is experimental or internal-only belong here. An experimental option inside an otherwise stable package stays in that package's product-role group. +- All Cordis plugin packages whose full public contract is experimental or internal-only belong here. An experimental option inside an otherwise stable package stays in that package's product-role group. - Use this directory to share engineering and product-manager prototypes across the team so others can discover, run, review, and extend them against the real plugin graph. - Official releases exclude this directory. A package enters a release only after moving to its product-role group; do not add packages here to release manifests or bundles. -- Experimental packages carry no stability, compatibility, migration, or support promise. Internal-only packages may define narrower internal contracts but make no public release promise. +- Experimental packages carry no stability, compatibility, migration, or support promise. Internal-only packages may define contracts for a limited set of internal callers and callees but make no public release promise. - Experimental or internal-only status never relaxes repository engineering, security, documentation, lifecycle, testing, or snapshot requirements. - Release packages must not take runtime dependencies on packages here. Examples may; every other runtime dependent is also experimental or internal-only and belongs here. Tests may use them as development dependencies. - Promotion moves a package to its product-role group without renaming its `@deepseek-ai/dsh-*` package. Require explicit review of its public contract, limitations, test evidence, and a named owner accepting stable-package obligations. diff --git a/packages/fs/fs-policy/src/types.ts b/packages/fs/fs-policy/src/types.ts index 9ee742a7f7..f2bd7187d3 100644 --- a/packages/fs/fs-policy/src/types.ts +++ b/packages/fs/fs-policy/src/types.ts @@ -1,9 +1,9 @@ /** * Vocabulary for the fs-policy plugin: the minimal execution-context - * shape used to derive an observed-state owner by narrowing the opaque `object` + * fields used to derive an observed-state owner by narrowing the opaque `object` * actor the `fs/*` events carry. * - * The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is + * The provider vocabulary (`FsTarget`, `FsVersion`, write/edit request types) is * re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state * owner structure on top of it. * @@ -12,10 +12,10 @@ /** * Minimal structural view of a tool execution the policy plugin needs to derive - * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies - * this shape, so the tool passes its `exec` straight through as the opaque - * `object` actor on the `fs/*` events; this plugin narrows that actor to this - * shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` contains + * these fields, so the tool passes its `exec` straight through as the opaque + * `object` actor on the `fs/*` events; this plugin narrows that actor to + * `FsPolicyExec` without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. * * The owner is `agent.session` when present. It is treated as an opaque object * identity (a `WeakMap` key); this package never reads any of its fields. diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index 49548499ac..c7e8ec7ee4 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -118,7 +118,7 @@ export function buildGrepCommand(input: GrepInput): string[] { /** * The uniform malformed-output failure: raw `rg --json` is an internal - * transport, so a shape surprise is a search failure, not a partial result. + * transport, so missing or invalid response fields cause a search failure, not a partial result. */ function malformedRecord(detail: string, cause?: unknown): SearchError { return new SearchError(`grep received malformed ripgrep --json output (${detail})`, 'SEARCH_FAILED', cause !== undefined ? { cause } : undefined) diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 7b2c3ae596..a7b82bdd06 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -24,7 +24,7 @@ interface EditInput { } /** - * The `edit` tool's validated argument shape: the base parameters plus the two + * The `edit` tool's validated arguments: the base parameters plus the two * escalation fields, advertised only under a confining `ctx.fs` (absent from * the schema otherwise, so the validator rejects them before `execute`). */ diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 9a74dfc387..ba96e32cd1 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -43,7 +43,7 @@ ${verb} file } /** - * The `write` tool's validated argument shape: the base parameters plus the + * The `write` tool's validated arguments: the base parameters plus the * two escalation fields, advertised only under a confining `ctx.fs` (absent * from the schema otherwise, so the validator rejects them before `execute`). */ diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index 6360e61a10..6396c7f75b 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -72,7 +72,7 @@ function nonNegativeInteger(value: unknown, field: string): number { /** Decode one canonical blocker explanation. */ function decodeBlockReason(value: unknown): GoalBlockReason { if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'code,message') { - throw new Error('goal change goal.blockedReason has an invalid shape') + throw new Error('goal change goal.blockedReason must have exactly code and message fields') } if (typeof value['code'] !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value['code'])) { throw new Error('goal change goal.blockedReason.code must be lower-kebab-case') @@ -102,7 +102,7 @@ function decodeSnapshot(value: unknown): GoalSnapshot { ? 'blockedReason,id,maxGoalRounds,objective,phase,revision' : 'id,maxGoalRounds,objective,phase,revision' if (Object.keys(value).sort().join(',') !== expectedKeys) { - throw new Error('goal change goal has an invalid shape') + throw new Error(`goal change goal for phase ${phase} must have exactly ${expectedKeys} fields`) } return { id: GoalId(value['id']), @@ -117,7 +117,7 @@ function decodeSnapshot(value: unknown): GoalSnapshot { /** Decode and validate one ref. */ function decodeRef(value: unknown): GoalRef { if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'id,revision') { - throw new Error('goal clear tombstone has an invalid shape') + throw new Error('goal clear tombstone must have exactly id and revision fields') } if (typeof value['id'] !== 'string' || value['id'].length === 0) { throw new Error('goal clear tombstone id must be a non-empty string') @@ -139,7 +139,7 @@ export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined { if (value['operation'] === 'clear') { const allowed = ['cleared', 'clearedAt', 'kind', 'operation', 'version'] if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) { - throw new Error('goal clear change has an invalid shape') + throw new Error(`goal clear change must have exactly ${allowed.sort().join(',')} fields`) } return { kind: 'goal/change', @@ -155,7 +155,7 @@ export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined { } const allowed = ['createdAt', 'goal', 'kind', 'operation', 'roundsStarted', 'updatedAt', 'version'] if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) { - throw new Error('goal snapshot change has an invalid shape') + throw new Error(`goal snapshot change must have exactly ${allowed.sort().join(',')} fields`) } const createdAt = nonNegativeInteger(value['createdAt'], 'createdAt') const updatedAt = nonNegativeInteger(value['updatedAt'], 'updatedAt') diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 3c642d2a8c..0c0a8e8373 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -502,8 +502,8 @@ describe('GoalService mutations', () => { session.append('goal/change', change) session.append('goal/change', { ...change, operation: 'edit', extra: true } as never) - expect(() => ctx.goals.get(agent)).toThrow('invalid shape') - expect(() => ctx.goals.get(agent)).toThrow('invalid shape') + expect(() => ctx.goals.get(agent)).toThrow('snapshot change must have exactly') + expect(() => ctx.goals.get(agent)).toThrow('snapshot change must have exactly') }) }) @@ -609,13 +609,13 @@ describe('goal replay validation', () => { expect(() => foldGoal(session.events)).toThrow('not the next admitted round') }) - it('rejects unsupported versions, operations, and top-level shapes', () => { + it('rejects unsupported versions, operations, and extra top-level fields', () => { expect(() => decodeGoalChange({ ...snapshotChange(), version: 2 })).toThrow('unsupported goal change version') expect(() => decodeGoalChange({ ...snapshotChange(), operation: 'explode' })).toThrow('operation is invalid') - expect(() => decodeGoalChange({ ...snapshotChange(), extra: true })).toThrow('snapshot change has an invalid shape') + expect(() => decodeGoalChange({ ...snapshotChange(), extra: true })).toThrow('snapshot change must have exactly') expect(() => decodeGoalChange({ kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'x', revision: 2 }, clearedAt: 1, extra: true, - })).toThrow('clear change has an invalid shape') + })).toThrow('clear change must have exactly') }) it('rejects invalid create and missing-current mutation sequences', () => { diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 4fe2b7806e..c30ee60ec2 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: d5afd21033afd8291c8059fb225deee3965f8b65 -README.zh.md: cde0b4fd286579f5b389bc601750ca8303b35f15 +README.md: 64f6ae7bcd92735f821c8f8d2b3b93203dbac17e +README.zh.md: 680fcee730674a21b5c2407247ce46b1a01cf6f3 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index d5afd21033..64f6ae7bcd 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle. +The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle. ## The shared Agent default (`agent-default-model` Settings section) @@ -30,9 +30,9 @@ Question responses are validated against their pending request before the first Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. -`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. +`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) records why the anchor maps to that `turn/end`. -Session model selection is a session-domain contract. `session.models` returns the current `ModelSelection` separately from provider-grouped advisory models, exact-model reasoning metadata, and provider-local lookup failures. The selection may be absent from the groups and is never injected as a synthetic row; clients can prompt for another selection without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and assigns the complete selection for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable provider or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the selected provider. This is deliberately not derivable from the groups because an adapter may serve an unadvertised model. `session.prompt` refuses on the same fact with `model-unavailable` before opening a turn; a disabled composer is a client affordance, and the method remains callable. +Session model selection is a session-domain contract. `session.models` returns the current `ModelSelection` separately from provider-grouped advisory models, exact-model reasoning metadata, and provider-local lookup failures. The selection may be absent from the groups and is never injected as a synthetic row; clients can prompt for another selection without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and assigns the complete selection for the next prompt assembly. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable provider or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the selected provider. This is deliberately not derivable from the groups because an adapter may serve an unadvertised model. `session.prompt` refuses on the same fact with `model-unavailable` before opening a turn; a disabled composer is a client affordance, and the method remains callable. 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. @@ -68,7 +68,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Pending-interaction state is host-side** — the wire shape is POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries. +- **Pending-interaction state is host-side** — the wire uses POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries. - **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. An unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. - **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cde0b4fd28..680fcee730 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -所有客户端形态共用的 API 网关:TS 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。 +所有客户端共用的 API 网关由三部分组成:TypeScript API 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?}`,提供 `ctx.apiProxy`)。该包不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。 ## 共享 Agent 默认值(`agent-default-model` Settings 分节) @@ -30,9 +30,9 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 -`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 +`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)记录了为何锚点要映射到该 `turn/end`。 -会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理(reasoning)元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户作出另一项选择,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定将在下一提示词组装边界使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。 +会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理(reasoning)元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户作出另一项选择,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定下次组装提示词时使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。 待处理的 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 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 @@ -60,7 +60,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 模型体验 -无。该包定义客户端与宿主间的协议约定和载体,其中没有任何内容会进入模型请求。 +无。该包定义客户端与宿主间的 wire 约定和载体,其中没有任何内容会进入模型请求。 #### KV Cache 影响 @@ -68,7 +68,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 已知限制与暂缓事项 -- **待处理交互状态位于宿主侧**:协议形状为 POST `/api/respond` 加 `RpcReceipt`;`src/api-proxy.ts` 中的表只处理问题,不包含审批条目。 +- **待处理交互状态位于宿主侧**:wire 使用 POST `/api/respond` 加 `RpcReceipt`;`src/api-proxy.ts` 中的表只处理问题,不包含审批条目。 - **预留 seam 不进入 `RpcMethodMap`**:`prompt.mode: 'inject'`、`task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。 diff --git a/packages/host/apiproxy/src/api/approvals.schema.ts b/packages/host/apiproxy/src/api/approvals.schema.ts index 6c6e90fb17..2790d98a97 100644 --- a/packages/host/apiproxy/src/api/approvals.schema.ts +++ b/packages/host/apiproxy/src/api/approvals.schema.ts @@ -10,7 +10,7 @@ import type { ApprovalResponsePayload } from './approvals.ts' import type { Wire } from './rpc.schema.ts' import { sessionIdSchema } from './sessions.schema.ts' -/** ApprovalRequestId: one brand cast after shape validation (the only cast point in this domain). */ +/** ApprovalRequestId: one brand cast after schema validation (the only cast point in this domain). */ export const approvalRequestIdSchema = z.string().min(1) as unknown as z.ZodType<ApprovalRequestId> /** Approval answer payload (the result.value slot of a client-response). */ diff --git a/packages/host/apiproxy/src/api/commands.schema.ts b/packages/host/apiproxy/src/api/commands.schema.ts index 89bfa76aa2..c135c82e5a 100644 --- a/packages/host/apiproxy/src/api/commands.schema.ts +++ b/packages/host/apiproxy/src/api/commands.schema.ts @@ -33,7 +33,7 @@ export const commandExecuteRequestSchema = z.object({ line: z.string(), }) satisfies z.ZodType<Wire<RequestPayload<'command.execute'>>> -/** CommandId: one brand cast after shape validation (the only cast point in this domain). */ +/** CommandId: one brand cast after schema validation (the only cast point in this domain). */ export const commandIdSchema = z.string().min(1) as unknown as z.ZodType<CommandId> /** command.execute response value: pure admission — outcomes ride the logged diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index fc841f9edb..c06efb9057 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -15,7 +15,7 @@ import { } from './sessions.schema.ts' import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts' -/** Question shape validated strictly against core dsh-user-interaction. */ +/** Question fields validated strictly against core dsh-user-interaction. */ export const askUserQuestionItemSchema = z.object({ id: z.string(), question: z.string(), diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 145b0500cf..f591283aec 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -23,8 +23,8 @@ export type Wire<T> = T extends readonly (infer E)[] ? Wire<E>[] : T /** - * RpcId: one brand cast after shape validation (the only cast point in this - * file). No min-length: the id is an opaque echo token, and rejecting shapes + * RpcId: one brand cast after schema validation (the only cast point in this + * file). No min-length: the id is an opaque echo token, and rejecting values * here would only turn a correlatable error report into a client-side parse * failure (the handler substitutes a sentinel when a request's id is unreadable). */ diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index e0ce444acf..81e150bc20 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -23,7 +23,7 @@ import { truncateUnicodeCodePoints, } from './session-search.ts' -/** SessionId: one brand cast after shape validation (the only cast point in this domain). */ +/** SessionId: one brand cast after schema validation (the only cast point in this domain). */ export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId> /** MessageId: one brand cast after non-empty string validation. */ diff --git a/packages/host/directory-picker-auto/README.i18n.yaml b/packages/host/directory-picker-auto/README.i18n.yaml index 49b198d446..22a7a47e27 100644 --- a/packages/host/directory-picker-auto/README.i18n.yaml +++ b/packages/host/directory-picker-auto/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/directory-picker-auto/README.md -README.md: f1715566c8aff8be90cab381bcedd4732d0b41f6 -README.zh.md: 9fc8e539d40a126b30be6dce02257bd9abe37944 +README.md: b1bbe4f97cdb88d8cf9bfe435c0eb6517554338b +README.zh.md: dc67456e9b86636522406bf6a57929b24793dade diff --git a/packages/host/directory-picker-auto/README.md b/packages/host/directory-picker-auto/README.md index f1715566c8..b1bbe4f97c 100644 --- a/packages/host/directory-picker-auto/README.md +++ b/packages/host/directory-picker-auto/README.md @@ -16,6 +16,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a darwin process outside an Aqua session still counts as displayed; and the `ssh -L` shape (a workstation-local launch later reached through a forwarded port, which arrives from `127.0.0.1`) resolves `native` and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly pins the safe interaction for such deployments. +- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a Darwin process outside an Aqua session still counts as displayed; and a workstation-local launch later reached through `ssh -L` arrives from `127.0.0.1`, resolves `native`, and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly selects the safe interaction for such deployments. - **The Linux chooser probe reads `PATH` only** — a zenity/kdialog reachable some other way (shell alias, non-PATH install) still resolves `browse`; installing either binary on `PATH` restores `native` eligibility at the next boot. - **Boot-time only** — one resolution serves every client of the boot; per-connection adaptivity (native for a local browser, browse for a remote one, same server) would need a per-client capability and the wire advertisement the seam deliberately deleted, and waits for a deployment that serves both at once. diff --git a/packages/host/directory-picker-auto/README.zh.md b/packages/host/directory-picker-auto/README.zh.md index 9fc8e539d4..dc67456e9b 100644 --- a/packages/host/directory-picker-auto/README.zh.md +++ b/packages/host/directory-picker-auto/README.zh.md @@ -16,6 +16,6 @@ ## 已知限制与暂缓事项 -- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即固定住安全的交互。 +- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 Darwin 进程仍被算作有显示;在工作站本地启动、之后经 `ssh -L` 访问时,请求会从 `127.0.0.1` 到达,系统会判定 `native`,并把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即选择安全的交互。 - **Linux 选择器探查只读 `PATH`**——以其他途径可用的 zenity/kdialog(shell 别名、未装在 PATH 上)仍判定为 `browse`;把任一二进制装到 `PATH` 上,下次启动即恢复 `native` 资格。 - **仅在启动时判定**——一次判定服务本次启动的所有客户端;按连接自适应(同一台服务器,本地浏览器用 native、远程浏览器用 browse)需要按客户端的能力对象以及 seam 有意删除的 wire 广播,等到出现同时服务两种形态的部署再做。 diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index aeb36e44d7..5ba3a2bed1 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/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/directory-picker/README.md -README.md: 3749b238b56578ec68610bc13550760aa084bad6 -README.zh.md: bc77a9c6e1d76e00926774dc518fce42b2860735 +README.md: d90f939aca57b6bc520bb96b56b8a7738b69a522 +README.zh.md: 40d82b3d60ab7d27100133385a73f31d8cb3c26a diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index 3749b238b5..d90f939aca 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. A composition that should not pin an interaction mounts the [`-auto`](../directory-picker-auto/README.md) chooser instead, which resolves the host's situation once at boot and mounts the matching backend row itself. +The web GUI host's workspace-directory picker is a capability seam. The abstract `DirectoryPicker` service (`ctx.directoryPicker`) is its Service Definition. Its only method, `capability()`, returns a discriminated union describing how an operator selects a directory. Backends differ in user interaction, not just implementation: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` provides listing and creation operations for an in-app browser, which works for remote clients that cannot reach an OS chooser ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map, and a new backend adds its variant there through declaration merging. For an unknown kind, consumers hide directory picking rather than fail. The capability object must be stable for the service lifetime. Each backend package also has a browser entrypoint that registers the matching interaction in ui-workspace's directory-flow slots, so one composition row selects both the host capability and the client flow. A composition that should choose at runtime mounts [`-auto`](../directory-picker-auto/README.md), which inspects the host once at boot and mounts the matching backend row. Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). @@ -16,4 +16,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No multi-root vocabulary** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the seam Agent Note. +- **No multi-root support** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the DirectoryPicker Agent Note. diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index bc77a9c6e1..40d82b3d60 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一约定方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,也能服务于 OS 对话框无法触及的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端通过声明合并加入自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam,无需通过 wire 公布能力:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一项组合配置会同时切换宿主能力与 client 流程。不应固定某种交互的组合改为挂载 [`-auto`](../directory-picker-auto/README.md) 选择器,它在启动时一次性判定宿主处境,并自行挂载匹配的后端行。 +web GUI 宿主的工作区目录选择是一项能力 seam。抽象的 `DirectoryPicker` 服务(`ctx.directoryPicker`)是其 Service Definition。该服务只提供一个方法:`capability()`,它返回一个可辨识联合类型,说明操作者如何选择目录。后端之间的用户交互不同,不只是实现不同:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器使用的列举与创建操作,也能服务于无法访问 OS 对话框的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生,新后端通过声明合并在其中加入自己的变体。遇到未知 kind 时,消费方会隐藏目录选择入口,而不是失败。能力对象在服务生命周期内必须保持稳定。每个后端包还提供 browser 入口,在 ui-workspace 的 directory-flow slot 中注册匹配的交互,因此一项组合配置会同时选择宿主能力与 client 流程。需要在运行时选择交互的组合挂载 [`-auto`](../directory-picker-auto/README.md),它在启动时检查一次宿主情况,并挂载匹配的后端行。 浏览原语失败时会抛出带类型的 `DirectoryPickerError`(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带出错对象的 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 @@ -16,4 +16,4 @@ web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker` ## 已知限制与暂缓事项 -- **约定未定义多根目录词汇**——浏览约定每次列举只暴露一条祖先链;按部署限定可浏览根(以及 Windows 盘符之上的根枚举)等到出现需要它的消费方再做,见 seam Agent Note。 +- **不支持多根目录**——浏览约定每次列举只公开一条祖先链;按部署限定可浏览根(以及在盘符根的上一级枚举 Windows 各盘符根目录)等到出现需要它的消费方再做,见 DirectoryPicker Agent Note。 diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index a9e5d9e48a..ecefc4db11 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/webserver/README.md -README.md: 569c3f0c19db2c308beaef35baaf915fd39768cd -README.zh.md: 3aee06487743764bf2cb837360bb1ac9f0268508 +README.md: c41001fba3a69bfd7c00550d0be602e3fc2e0474 +README.zh.md: 061bed977e456ba6c3cd38f5ad3d30fe0c9354ab diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 569c3f0c19..c41001fba3 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` claims the single fallback seat answering everything no named route matches — one owner only (a second claim throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner), 404 while unclaimed. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order — the fallback owner calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback seat. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. +Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order; the fallback handler calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. -The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. +The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). This server serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. This package never prints; the URL line belongs to the shell. A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a fallback owner's `decodeURIComponent` on a malformed %-escape, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 3aee064877..061bed977e 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 认领唯一的回退席位,应答所有未被具名 route 命中的请求:只允许一个持有者(第二次认领会抛错;随附的持有者是 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md)),席位未被认领时返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换:fallback 持有者在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退席位。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。 +Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换;fallback handler 在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。 -该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 +该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认值)和 `0.0.0.0`(有意向网络开放)。该服务器只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。该包从不打印内容;URL 行属于 shell。 监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如 fallback 持有者的 `decodeURIComponent` 收到格式错误的百分号转义,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。 diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index fbd275eeed..2ff04379e3 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -50,12 +50,11 @@ export interface Config { } /** - * The web-shape HTTP carrier service. Activation listens immediately (route - * registration order carries no request-facing semantics: named routes are - * composed to be disjoint, and the fallback seat answers anything not yet - * claimed during the boot window — 404 until its owner registers). A listen - * failure throws out of init — a FAILED fiber the boot's fail-loud sweep - * reports. + * The browser HTTP carrier service. Activation listens immediately. Route + * registration order does not affect requests because configured named routes + * must be distinct, and the fallback handler answers anything not yet claimed + * during startup with 404 until its owner registers. A listen failure rejects + * initialization, and the boot process reports the failed fiber. */ export class HttpServerService extends Service { static Config: z<Config> = z.object({ @@ -224,8 +223,8 @@ export class HttpServerService extends Service { }) }) - // Node does not include upgraded sockets in closeAllConnections(), so the - // service tracks and destroys them as part of the same ownership boundary. + // Node does not include upgraded sockets in closeAllConnections(). The service + // owns them with the other connections, so it tracks and destroys them explicitly. this.ctx.effect(() => async () => { const serverClosed = new Promise<void>((resolve) => { this.server.close(() => { resolve() }) diff --git a/packages/interaction/permission/src/invariant.ts b/packages/interaction/permission/src/invariant.ts index b1290b7307..3bd102645f 100644 --- a/packages/interaction/permission/src/invariant.ts +++ b/packages/interaction/permission/src/invariant.ts @@ -11,7 +11,7 @@ export const name = 'permission-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate the package-owned event shape and ignore unrelated events. */ +/** Validate the package-owned event fields and ignore unrelated events. */ function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure): void { if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) { fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`) diff --git a/packages/interaction/user-interaction/README.i18n.yaml b/packages/interaction/user-interaction/README.i18n.yaml index 4537cfd7da..55b9514b60 100644 --- a/packages/interaction/user-interaction/README.i18n.yaml +++ b/packages/interaction/user-interaction/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/interaction/user-interaction/README.md -README.md: cf000dd59754dfe2f14395c33384bad5bda76910 -README.zh.md: 67167649e29afe99667ab6c863127d27d4bceb48 +README.md: a1fe8e63011b0726e67f8b873b0b67f4af2e890a +README.zh.md: a6a0750bd91a316ebfeaef7859d5079f7ee8b616 diff --git a/packages/interaction/user-interaction/README.md b/packages/interaction/user-interaction/README.md index cf000dd597..a1fe8e6301 100644 --- a/packages/interaction/user-interaction/README.md +++ b/packages/interaction/user-interaction/README.md @@ -26,7 +26,7 @@ When a request carries an agent, `ask()` authenticates its exact identity throug ### Presentation intent -`intent` declares that a question IS a decision of a known shape, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent shapes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read one answer shape either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of. +`intent` declares that a question IS a known kind of decision, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent changes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read the same answer fields either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of. ## Role diff --git a/packages/interaction/user-interaction/README.zh.md b/packages/interaction/user-interaction/README.zh.md index 67167649e2..a6a0750bd9 100644 --- a/packages/interaction/user-interaction/README.zh.md +++ b/packages/interaction/user-interaction/README.zh.md @@ -26,7 +26,7 @@ ### 呈现意图 -`intent` 声明某个问题本身就是一种已知形态的决策,因此认识该标签的 UI 可以照此呈现——`plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只塑造呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上——而 `detail` 正是它自称在审阅的东西。 +`intent` 声明某个问题本身就是一种已知决策,因此认识该标签的 UI 可以照此呈现——`plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只改变呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的回答字段相同。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上——而 `detail` 正是它自称在审阅的东西。 ## 职责 diff --git a/packages/interaction/user-interaction/src/types.ts b/packages/interaction/user-interaction/src/types.ts index 51edfc6bf7..81be220592 100644 --- a/packages/interaction/user-interaction/src/types.ts +++ b/packages/interaction/user-interaction/src/types.ts @@ -1,5 +1,5 @@ /** - * Wire-safe question/answer shapes, free of cordis/service imports so browser + * Wire-safe question and answer types, free of cordis/service imports so browser * type chains (apiproxy api → client) can consume them without loading this * package's Context augmentation. * @module @deepseek-ai/dsh-user-interaction/types @@ -14,11 +14,11 @@ export interface AskUserQuestionOption { } /** - * A caller-declared presentation intent: the question IS a decision of this - * shape, so a UI that recognises the tag may present it as such instead of as a + * A caller-declared presentation intent: the question IS this kind of + * decision, so a UI that recognises the tag may present it as such instead of as a * generic option list. Tagged so further intents can be added; a UI that does * not know a tag renders the generic flow, and the answer encoding is identical - * either way — an intent shapes presentation only, never the protocol. + * either way — an intent changes presentation only, never the protocol. */ export type AskUserQuestionIntent = { /** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */ diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 840a8c2865..4011ff5ba9 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: 7151fdf5b63f48e625d00a92dc42aa24b7de2f31 -README.zh.md: 0bfd5c706e01dd4448edb9cf0eec812831f68093 +README.md: f6a1eefe6083d801009a5b788a07b58d6e696a5a +README.zh.md: f4c5ddd6dbe05ae709145cfac341f17a716bac82 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 7151fdf5b6..f6a1eefe60 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -173,7 +173,7 @@ Conversion preserves logical request order without adding text, while the select #### What the model sees -pi-ai events become harness reasoning, text, tool-call, usage, and finish chunks. Parsed tool arguments cross the harness boundary as raw JSON strings. +pi-ai events become harness reasoning, text, tool-call, usage, and finish chunks. The adapter passes parsed tool arguments to the harness as raw JSON strings. #### Token effect diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 0bfd5c706e..f4c5ddd6db 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -173,7 +173,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### 模型看到的内容 -pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish 分片。已解析工具参数以原始 JSON 字符串形式通过 harness 边界传递。 +pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish 分片。适配器把解析后的工具参数作为原始 JSON 字符串传给 harness。 #### Token 影响 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 4e0cf3c092..90cf145975 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -145,12 +145,12 @@ export type PiAiReasoningEfforts = Partial<Record<ModelThinkingLevel, string | n * default) or per model (winning over the route). Only the switches pi-ai's * reasoning dispatch reads are offered; the rest of pi-ai's compat surface * keeps its baseURL-derived auto-detection. pi-ai types both fields only on - * `OpenAICompletionsCompat` — the other wire protocols carry their reasoning - * shape in the protocol itself — so resolution rejects a model-level switch + * `OpenAICompletionsCompat` — the other wire protocols define their reasoning + * fields in the protocol itself — so resolution rejects a model-level switch * anywhere else, while a route-level default skips past models it cannot fit. */ export interface PiAiCompatProfile { - /** Reasoning parameter shape the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + /** Reasoning parameter format the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ thinkingFormat?: PiAiThinkingFormat /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ supportsReasoningEffort?: boolean diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index e52af4a3f4..a2074302e8 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -163,12 +163,12 @@ const compatProfile: z<PiAiCompatProfile> = z.object({ /** * Keys are the offered levels, values their wire spellings. A valueless key * (`off:`) survives validation because schemastery passes nullable data - * through before any member schema runs — `z.const(null)` only shapes the - * error for non-null wrong values and what a configuration surface renders. + * through before any member schema runs — `z.const(null)` only controls the + * error for non-null wrong values and what a configuration UI renders. * Only resolution decides which levels may leave the value empty, so the * diagnostic can name the route and model. The assertion narrows * schemastery's `Dict`, which types every literal key as required; dict - * validation is per-present-key, so the runtime shape is the partial record. + * validation checks only present keys, so the runtime value is a partial record. */ const reasoningEfforts = z.dict( z.union([z.string(), z.const(null)]), @@ -237,7 +237,7 @@ export function assertServiceable(config: Config): void { resolveProfiles(config.providers) } -/** Reject a pre-release profile shape, naming the replacement. */ +/** Reject removed pre-release profile fields and name their replacements. */ function rejectRemovedFields(provider: string, source: PiAiProviderProfile): void { const legacy = source as PiAiProviderProfile & { provider?: unknown diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 8a7dec1265..75d8d6f364 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -184,8 +184,8 @@ export interface PreparedLlmCall { /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch - * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove the headers are added in the wire request or library header hook. The direct-fetch + * DeepSeek and library-backed pi-ai adapters meet this contract through different internals. */ export abstract class LlmAdapter { /** diff --git a/packages/llm/llm/src/message.ts b/packages/llm/llm/src/message.ts index 2673072fa0..7863e66d58 100644 --- a/packages/llm/llm/src/message.ts +++ b/packages/llm/llm/src/message.ts @@ -30,8 +30,8 @@ export interface ToolMessageSource { } /** - * What SHAPE of information a producer-supplied context carries, declared by - * the producer beside the source fields it supplied. + * The kind of information in producer-supplied context, declared by the + * producer beside its provenance. * * `MessageSource.kind` answers *who produced this*; `form` answers *what kind * of thing it is*, and the two axes are deliberately independent — several @@ -69,10 +69,10 @@ export interface ContextSnapshotSection { /** * Producer-declared {@link ContextForm} and the fields that form requires, - * mixed into the source shapes that carry one. + * mixed into the source types that carry one. * - * Discriminated by `form` so a producer cannot declare a shape without the - * facts that shape is presented from: a `notice` must record its one-line + * Discriminated by `form` so a producer cannot select a form without the + * fields needed to present it: a `notice` must record its one-line * account, a `snapshot` its sections. Omitting `form` stays valid — an * undeclared context is the documented default. */ diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index ad7e8f66ba..70528bf53a 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -1,6 +1,6 @@ /** * Canonical provider-neutral message and streaming vocabulary for the loop, - * session log, and plugins. Adapters alone translate provider wire shapes; + * session log, and plugins. Adapters alone translate provider wire messages; * mapped interfaces make the content, source, and finish unions extensible. */ @@ -21,13 +21,13 @@ export type { UserMessage, } from './message.ts' -/** Serializable provider-boundary facts; policy decides whether they are retryable. */ +/** Serializable provider or transport failure facts; policy decides whether they are retryable. */ export interface LlmFailure { /** Human-readable provider or transport failure. */ readonly message: string /** Stable provider-neutral machine-routing code. */ readonly code: string - /** HTTP status observed at the provider boundary, when available. */ + /** HTTP status returned by the provider, when available. */ readonly status?: number /** Provider-requested delay in milliseconds, when valid and available. */ readonly providerRetryAfterMs?: number @@ -89,7 +89,7 @@ export interface ContentBlockMap { 'tool-result': ToolResultBlock } -/** The block `type` tag vocabulary; widens as plugins merge new shapes into {@link ContentBlockMap}. */ +/** The block `type` tag vocabulary; widens as plugins add entries to {@link ContentBlockMap}. */ export type ContentBlockType = keyof ContentBlockMap /** Any known content block, derived from {@link ContentBlockMap}; switch on `type` and fall through unknowns (merge-extensible). */ export type ContentBlock = ContentBlockMap[ContentBlockType] diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index d3b024a2ee..a5ac463fb2 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -206,7 +206,7 @@ export class TokenMeterService extends Service { if (state.stepStart === undefined || state.stepStart.turn !== event.data.turn || state.stepStart.step !== event.data.step) { - throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start boundary`) + throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start event`) } nextStepStart = undefined break @@ -223,7 +223,7 @@ export class TokenMeterService extends Service { if (stepStart === undefined || stepStart.turn !== event.data.turn || stepStart.step !== event.data.step) { - throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start boundary`) + throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start event`) } // assistant/message is surface-mandatory at every append/seed boundary. diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index 2a9323474e..3e0f9559e9 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/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/plan/plan-mode/README.md -README.md: c404cfa73024804bc9f166cfb84fa5f87f723459 -README.zh.md: 275a87669802f38cd98886236ca63a09ffb3e410 +README.md: d7e19cc473695455df667cfd717703c2c303aafa +README.zh.md: e89b75df184d2283452ab069a2d559650f15bfef diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index c404cfa730..d7e19cc473 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Logged, per-agent plan collaboration state with deployment-owned guidance, direct `/plan [message]` entry and `/plan off` exit commands, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy remain independent enforcement axes. +Logged, per-agent plan collaboration state with deployment-owned guidance, direct `/plan [message]` entry and `/plan off` exit commands, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy enforce restrictions independently and do not read or write plan state. ## Durable state `plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`. -`ctx.planMode.set(agent, active)` commits immediately when the agent is idle — no boundary would arrive until the next prompt, so the standalone `plan/mode` event lands at once — and holds a pending selection for the next accepted in-turn pre-step while the agent is running; it returns which of the two happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's mid-turn selection. Initial and continuation pre-step boundaries are covered; a same-step request-recovery retry reuses its frozen assembly and leaves the selection pending for the next pre-step. A changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths). +`ctx.planMode.set(agent, active)` appends the standalone `plan/mode` event immediately when the agent is idle, because no in-turn pre-step runs before the next prompt. While the agent is running, it holds a pending selection for the next accepted in-turn pre-step. It returns which happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state used to assemble the current step from a user's mid-turn selection. Initial and continuation pre-steps both apply pending selections; a same-step request-recovery retry reuses its frozen assembly and leaves the selection pending for the next pre-step. A changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths). ## Model and human surfaces @@ -22,7 +22,7 @@ The Web client consumes the plugin-owned `/plan` command; other entry points may ## Session projection -When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, keeping the logged request and the run plane from forking). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. +When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, so a failed handler cannot leave a recorded command without its plan selection). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. ## Configuration @@ -91,8 +91,8 @@ Mode transitions do not change the tool catalog; plan arguments and review resul ## Known Limitations and Deferred Work -- Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls. -- A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it. +- Plan mode guides rather than enforces; deployments that need enforced restrictions must configure sandbox and approval controls independently. +- A selection made after the turn's final accepted pre-step is lost if the process exits before another accepted in-turn pre-step, so the UI must reapply it. - Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option. - A live child owned by another agent cannot open the `exit_plan_mode` review. The failed call tells the child to include the unresolved decision in its final result; durable fork lineage alone does not prevent a session resumed as a runtime root from opening the review. - Only the Web UI has a specialized `plan-review` renderer; another interaction provider may present the same request through its generic option flow. diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 275a876698..e89b75df18 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -按 agent(智能体)分别记录到日志的 plan 协作状态,提供由部署方配置的引导内容、用于直接进入的 `/plan [message]` 命令、用于直接退出的 `/plan off` 命令,以及经用户评审的 `exit_plan_mode` 退出方式。Plan mode 是软引导;沙箱模式和批准策略仍是独立的强制执行维度。 +按 agent(智能体)分别记录到日志的 plan 协作状态,提供由部署方配置的引导内容、用于直接进入的 `/plan [message]` 命令、用于直接退出的 `/plan off` 命令,以及经用户评审的 `exit_plan_mode` 退出方式。Plan mode 是软引导;沙箱模式和批准策略各自强制执行限制,且不读写 plan 状态。 ## 持久状态 `plan/mode`(`{ active: boolean }`)是一个仅存在于日志中、每次以完整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。 -`ctx.planMode.set(agent, active)` 在 agent 空闲时立即提交——下一个 prompt 之前不会有任何边界到来,因此独立的 `plan/mode` 事件当场落账——在 agent 运行中则持有待生效选择,并等待下一个被接受的轮内 pre-step;返回值区分 `committed`、`queued`、表示反转的 `cancelled` 和 `noop`。`get(agent)` 返回 `{ active, pending? }`,将塑造当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 边界都在覆盖范围内;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。 +`ctx.planMode.set(agent, active)` 会在 agent 空闲时立即追加独立的 `plan/mode` 事件,因为下一个 prompt 之前不会运行轮内 pre-step。agent 运行时,该方法会保留待生效选择,直到下一个被接受的轮内 pre-step。返回值区分 `committed`、`queued`、表示反转的 `cancelled` 和 `noop`。`get(agent)` 返回 `{ active, pending? }`,将用于组装当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 都会应用待生效选择;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个被接受的轮内 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条追加路径皆然)。 ## 模型与人类交互 @@ -16,13 +16,13 @@ 评审问题声明 `plan-review` 呈现意图,并指名 `Approve` 为表示批准的标签,因此有能力的 UI 会把计划呈现为一次决定而非通用问题;两种情况下该工具读到的回答完全相同。放弃审阅 —— 用户关掉请求改用说话 —— 会如实报告给模型,要求它留在 plan mode 中等待那条消息;其余每一种评审失败都保留 seam 自身的消息。 -组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择到达请求边界之前将其取消。 +组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择由轮内 pre-step 追加之前将其取消。 Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。 ## 会话投影 -当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,避免已写入日志的请求与运行面分叉。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 +当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,因此处理器失败时不会留下缺少对应 plan 选择的已记录命令。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 ## 配置 @@ -91,8 +91,8 @@ mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩 ## 已知限制与暂缓事项 -- Plan mode 只进行引导,而不强制执行;需要硬边界的部署必须组合独立的沙箱与批准控制。 -- 如果进程在下一个边界之前退出,空闲时作出的待生效选择会丢失,因此 UI 必须重新应用它。 +- Plan mode 只进行引导,而不强制执行;需要强制限制的部署必须分别配置沙箱与批准控制。 +- 如果进程在另一个被接受的轮内 pre-step 之前退出,某轮最后一个被接受的 pre-step 之后作出的选择会丢失,因此 UI 必须重新应用它。 - Fork 的 agent 会继承已记录的 plan 状态,新 spawn 的 agent 则从未激活状态开始;不存在创建时 plan 选项。 - 由另一个 agent 所有的存活子级无法打开 `exit_plan_mode` 审阅。该调用失败时会提示子级在最终结果中包含尚未解决的决策;仅有持久化 fork 谱系并不会阻止恢复为运行时根的会话打开该审阅。 - 只有 Web UI 具备专用的 `plan-review` 渲染器;其他交互提供方可以通过通用选项流程呈现同一请求。 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 00234424ff..86da4d4935 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -1,20 +1,21 @@ /** * Plan mode is logged per-agent collaboration state: while active, a - * deployment-owned guidance section shapes each model request, and + * deployment-owned guidance section is included in each model request, and * `exit_plan_mode` presents the completed plan for user review, while the - * `/plan off` command lets a user leave directly. Plan mode is independent of - * sandbox mode and approval policy; those enforcement axes do not read or - * write plan state. + * `/plan off` command lets a user leave directly. Sandbox mode and approval + * policy enforce restrictions independently and do not read or write plan + * state. * * The state in force is folded from the session log (`plan/mode`, last one * wins), so resume and fork restore it without a live mirror. User selections - * are held as pending intent until an in-turn step boundary. The service - * projects pending intent into the proposed step assembly, then flushes it + * remain pending until the next accepted in-turn pre-step. The service includes + * the selected state in the proposed step assembly, then appends `plan/mode` * from `agent/pre-step` only when the step is accepted. Same-step request * retries reuse their assembly. * - * The exit tool remains registered while plan mode is inactive so crossing a - * boundary changes only the prompt section, not the request tool catalog. + * The exit tool remains registered while plan mode is inactive, so entering + * or leaving plan mode changes only the prompt section, not the request tool + * catalog. * * Agent Note: * - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md @@ -97,7 +98,7 @@ function firstHeading(plan: string): string | undefined { /** * Validate deployment-owned plan guidance. Missing, blank, non-string, or - * unknown fields fail at plugin load rather than silently shaping nothing. + * unknown fields fail at plugin load rather than being ignored. * * @param config Raw plugin config. * @returns A detached validated config. @@ -176,7 +177,7 @@ function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefi } /** - * `ctx.planMode`: owns logged plan state, boundary application and narration, + * `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, * the `plan:policy` section, the `/plan` command, and the stable exit tool. * UIs observe committed flips through `session/event`; there is no live mirror. */ @@ -187,7 +188,7 @@ export class PlanModeService extends Service { private readonly section: string /** - * Latest selection per session awaiting an in-turn request-boundary flush. + * Latest selection per session awaiting the next accepted in-turn pre-step. * `narrate` is true for user selections and false for the exit tool, whose * result already narrates the transition. */ @@ -197,10 +198,10 @@ export class PlanModeService extends Service { super(ctx, 'planMode') this.section = resolveConfig(config).section let disposed = false - // Pre-step is outside Session.append publication, so its log-only mode - // event can land between turns or inside an open turn without re-entering - // the session. A failed append remains pending for a later boundary, and - // policy cannot block the step. + // Pre-step is outside Session.append publication, so it can append the + // log-only mode event inside an open turn without re-entering the session. + // A failed append remains pending for a later accepted in-turn pre-step, + // and policy cannot block the step. ctx.on('agent/pre-step', async ( { agent, signal }, next, @@ -212,7 +213,7 @@ export class PlanModeService extends Service { try { this.onBoundary(agent.session) } catch (error) { - ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error) + ctx.logger.warn('dsh-plan-mode: failed to append selected plan mode at step start: %o', error) return decision } return !pending.narrate || narration === undefined @@ -234,8 +235,9 @@ export class PlanModeService extends Service { // The plan projection unit (session-projection RFC): a pure double-event // fold serving clients the whole {active, pending} value. `command/run` // records the user's logged /plan selection (the handler calls `set()` - // before any failing path, so log and run-plane cannot fork); `plan/mode` - // is the boundary commit that resolves it. Pending is thereby a pure + // before any failing path, so a failed handler cannot leave the recorded + // command without its plan selection); `plan/mode` records that selection + // and clears it. Pending is thereby a pure // replay quantity: host restarts, other tabs, and cold reads all recover // it from the log alone. The unit child activates only when a projection // registry is composed (headless assemblies stay unaffected). @@ -280,8 +282,9 @@ export class PlanModeService extends Service { case 'cancelled': return { kind: 'success', text: 'Plan mode entry cancelled.' } case 'noop': - // Repeat the queued wording while an exit still awaits its - // boundary; only a truly inactive session reads idempotent. + // Repeat the queued wording while an exit still awaits the + // next accepted pre-step; only a truly inactive session reads + // idempotent. return foldPlanMode(agent.session.events) ? { kind: 'success', text: 'Leaving plan mode (applies from the next step).' } : { kind: 'success', text: 'Plan mode is already inactive.' } @@ -357,8 +360,8 @@ export class PlanModeService extends Service { } throw cause }) - // A review may outlive this plugin fiber. Without boundary listeners, - // an approved result could never land, so fail and keep planning. + // A review may outlive this plugin fiber. Without its pre-step listener, + // an approved selection could never be appended, so fail and keep planning. if (disposed) { throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again') } @@ -371,7 +374,8 @@ export class PlanModeService extends Service { : `The user chose to keep planning; their feedback: ${feedback}`) } // Keep plan guidance for the rest of this assistant tool batch. The - // silent intent flushes after the step, before the next assembly. + // silent selection is appended at the next accepted in-turn pre-step, + // before its request assembly. this.pendingIntents.set(agent.session, { active: false, narrate: false }) return { approved: true } }, @@ -390,7 +394,8 @@ export class PlanModeService extends Service { } /** - * Read the logged plan state and any selected state awaiting a boundary. + * Read the logged plan state and any selected state awaiting the next + * accepted in-turn pre-step. * * @param agent The agent to read. * @returns Current logged state plus a pending selection, when present. @@ -402,20 +407,20 @@ export class PlanModeService extends Service { } /** - * Select whether plan mode should be active. Between turns the change - * commits immediately — no request boundary would arrive until the next - * prompt, so a queued intent would hang (the open-turn fold is the idle - * signal: agent status stays `running` through post-turn checkpointing, - * where a boundary equally never comes). During an open turn the - * selection is held as pending intent for the next in-turn request - * boundary. Repeated selection of the current or already-pending state is - * a no-op. + * Select whether plan mode should be active. Between turns the method + * appends the change immediately because no in-turn pre-step will run until + * another prompt starts a turn. The open-turn fold is the idle signal: + * agent status stays `running` through post-turn checkpointing, when no + * further in-turn pre-step runs. During an open turn the selection remains + * pending until the next accepted in-turn pre-step. Repeated selection of + * the current or already-pending state is a no-op. * * @param agent The agent to switch. * @param active Whether plan mode should be active. * @returns what happened: `committed` (logged now), `queued` (awaiting the - * next boundary), `cancelled` (an opposite pending selection was cleared; - * the logged state already matches), or `noop` (already in that state). + * next accepted in-turn pre-step), `cancelled` (an opposite pending selection + * was cleared; the logged state already matches), or `noop` (already in that + * state). */ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' { const session = agent.session @@ -439,7 +444,7 @@ export class PlanModeService extends Service { return 'committed' } - /** Flush one pending selection before the next request assembly. */ + /** Append one pending selection before the next request assembly. */ private onBoundary(session: Session): void { const pending = this.pendingIntents.get(session) if (pending === undefined) return @@ -449,8 +454,8 @@ export class PlanModeService extends Service { return } session.append('plan/mode', { active: target }) - // Delete only after append succeeds so a later boundary can retry a failed - // durable write. + // Delete only after append succeeds so a later accepted in-turn pre-step + // can retry a failed durable write. this.pendingIntents.delete(session) } diff --git a/packages/plan/plan-mode/src/types.ts b/packages/plan/plan-mode/src/types.ts index eafd5f0aff..a3c10d2252 100644 --- a/packages/plan/plan-mode/src/types.ts +++ b/packages/plan/plan-mode/src/types.ts @@ -11,8 +11,8 @@ /** * The plan projection's wire value. `active` is the logged state in force * (the last `plan/mode`, inactive before the first); `pending` is true while - * a logged `/plan` selection (`command/run`) awaits its request-boundary - * `plan/mode` commit and targets a state other than `active`. Capability + * a logged `/plan` selection (`command/run`) targets a state other than + * `active` and no later `plan/mode` event has recorded that state. Capability * absence (plan-mode not composed) is the key's absence, never a value. */ export interface PlanProjection { diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index c504ff5df6..b1e546d8b7 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -69,7 +69,7 @@ describe('plan projection unit', () => { expect(bench.values()).toEqual({ plan: { active: false, pending: false } }) }) - it('a logged /plan selection reads pending until the boundary commit resolves it', async () => { + it('a logged /plan selection reads pending until plan/mode records it', async () => { const bench = await harness(true) runPlanCommand(bench.session, '', 0) expect(bench.values().plan).toEqual({ active: false, pending: true }) diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 13fa52ce9d..ba2e21b8e9 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-local/README.md -README.md: 23d3a32451c105c71c0a7399ed051288b70753f3 -README.zh.md: 1890771faf8cab6b1842f973a999c7a9cf2dbb11 +README.md: 4d9e8275ba3fe0c1f49555b61e319f52194244bc +README.zh.md: 8a755e6c5b0c266538277bbbcd118fd24ab164f3 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 23d3a32451..4d9e8275ba 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -35,4 +35,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full. - **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it. - **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes. -- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-shaped profile honestly; if it is itself a Bash script, its interpreter startup runs before that script applies confinement. +- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-compatible profile honestly; if it is itself a Bash script, its interpreter startup runs before that script applies confinement. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 1890771faf..8a755e6c5b 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -35,4 +35,4 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list - **Landlock 可能只实现部分强制执行**:较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。 - **Seatbelt 依赖已弃用的 `sandbox-exec`**:macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。 - **runner 选择在提供方生命周期内缓存**:安装、移除或修复 runner 后,必须重载插件才能改变选择。 -- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现 bwrap 形式的 profile;如果它本身是 Bash 脚本,其解释器启动发生在该脚本施加约束之前。 +- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现与 bwrap 兼容的 profile;如果它本身是 Bash 脚本,其解释器启动发生在该脚本施加约束之前。 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 42a150b855..fc19a8dbea 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -42,7 +42,7 @@ import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './pr /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { /** - * Override the runner argv; bwrap-shaped profile arguments are appended. A + * Override the runner argv; bwrap-compatible profile arguments are appended. A * non-empty override asserts full enforcement and skips built-in selection and * probing. A runner that starts but refuses its profile must be identifiable by * {@link runnerFailureSignatures}. Consumers classify a spawn rejection only after diff --git a/packages/sandbox/sandbox-policy/src/invariant.ts b/packages/sandbox/sandbox-policy/src/invariant.ts index 90b8bf65fd..20fd176af6 100644 --- a/packages/sandbox/sandbox-policy/src/invariant.ts +++ b/packages/sandbox/sandbox-policy/src/invariant.ts @@ -13,7 +13,7 @@ export const name = 'sandbox-policy-invariant' export const inject = ['invariants'] /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ -/** Validate the package-owned event shape and ignore unrelated events. */ +/** Validate the package-owned event fields and ignore unrelated events. */ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { if (event.type === 'sandbox/mode' && !SANDBOX_MODES.includes(event.data.mode)) { fail(`sandbox/mode carries unknown mode ${JSON.stringify(event.data.mode)}`) diff --git a/packages/scaffold/client/src/api.ts b/packages/scaffold/client/src/api.ts index 6e76efa417..d615caece5 100644 --- a/packages/scaffold/client/src/api.ts +++ b/packages/scaffold/client/src/api.ts @@ -203,7 +203,7 @@ export function normalizeInput(input: string | ContentBlock[]): ContentBlock[] { return typeof input === 'string' ? [{ type: 'text', text: input }] : input } -/** Validate a wire `session.event` envelope to the shape the typed result exposes. */ +/** Validate the fields in a wire `session.event` envelope before returning the typed result. */ function validatedSessionEvent(value: unknown): SessionEvent { if (!isRecord(value) || typeof value.type !== 'string') { throw new SdkProtocolError(`session.event carried no event envelope: ${JSON.stringify(value)}`) diff --git a/packages/scaffold/helper/src/documents/tsconfig-file.ts b/packages/scaffold/helper/src/documents/tsconfig-file.ts index 368b93eb43..2f61e10951 100644 --- a/packages/scaffold/helper/src/documents/tsconfig-file.ts +++ b/packages/scaffold/helper/src/documents/tsconfig-file.ts @@ -70,7 +70,7 @@ export class TsConfigFile extends ProjectFile { )) } - /** Validate JSONC and the project-reference shape. */ + /** Validate JSONC and the project-reference fields. */ override validate(): void { const value = parseConfig(this.text) if (value.references === undefined) return diff --git a/packages/scaffold/helper/src/features/define-feature.ts b/packages/scaffold/helper/src/features/define-feature.ts index 84b020591c..720f15ecd6 100644 --- a/packages/scaffold/helper/src/features/define-feature.ts +++ b/packages/scaffold/helper/src/features/define-feature.ts @@ -114,7 +114,7 @@ function configDiagnostics( if (!expected || Object.keys(expected).length === 0) return undefined return config => Object.entries(expected).flatMap(([key, value]) => sameShape(value, config[key]) ? [] - : [`${key} has an incompatible value shape`]) + : [`${key} has fields or value types that do not match the expected config`]) } function resourcesFromSpec(spec: FeatureResourceSpec): ProjectResource[] { diff --git a/packages/scaffold/helper/src/features/feature.ts b/packages/scaffold/helper/src/features/feature.ts index 93d51c5ffe..6fec1e84e3 100644 --- a/packages/scaffold/helper/src/features/feature.ts +++ b/packages/scaffold/helper/src/features/feature.ts @@ -236,7 +236,7 @@ export abstract class Feature { } /** - * Inspect current files and reject any partial or ambiguous owned shape. + * Inspect current files and reject any partial or ambiguous owned file set. * @param project - project snapshot to inspect. * @returns installation state, selection, and diagnostics. */ diff --git a/packages/self-modification/repository-plugin/src/index.ts b/packages/self-modification/repository-plugin/src/index.ts index 1251fe45e2..46a020f40a 100644 --- a/packages/self-modification/repository-plugin/src/index.ts +++ b/packages/self-modification/repository-plugin/src/index.ts @@ -92,7 +92,7 @@ async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise process.env, directory, // Schemastery call signatures collapse the parameter to `never` under - // NodeNext; ResolvedMcpServer is shaped for the Config union by design. + // NodeNext; ResolvedMcpServer matches the Config union by design. ).map(input => McpClient.Config(input as never)) await ctx.effect(async function* () { diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index fdb852533d..929bddece9 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -486,7 +486,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'httpServer', - summary: 'The web-shape HTTP carrier service.', + summary: 'The browser HTTP carrier service.', methods: [ { signature: 'register(route: WebRoute): () => void', @@ -602,15 +602,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'planMode', - summary: '`ctx.planMode`: owns logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool.', + summary: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool.', methods: [ { signature: 'get(agent: Agent): { active: boolean; pending?: boolean }', - jsDoc: '/**\n * Read the logged plan state and any selected state awaiting a boundary.\n *\n * @param agent The agent to read.\n * @returns Current logged state plus a pending selection, when present.\n */', + jsDoc: '/**\n * Read the logged plan state and any selected state awaiting the next\n * accepted in-turn pre-step.\n *\n * @param agent The agent to read.\n * @returns Current logged state plus a pending selection, when present.\n */', }, { signature: 'set(agent: Agent, active: boolean): \'committed\' | \'queued\' | \'cancelled\' | \'noop\'', - jsDoc: '/**\n * Select whether plan mode should be active. Between turns the change\n * commits immediately — no request boundary would arrive until the next\n * prompt, so a queued intent would hang (the open-turn fold is the idle\n * signal: agent status stays `running` through post-turn checkpointing,\n * where a boundary equally never comes). During an open turn the\n * selection is held as pending intent for the next in-turn request\n * boundary. Repeated selection of the current or already-pending state is\n * a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n * @returns what happened: `committed` (logged now), `queued` (awaiting the\n * next boundary), `cancelled` (an opposite pending selection was cleared;\n * the logged state already matches), or `noop` (already in that state).\n */', + jsDoc: '/**\n * Select whether plan mode should be active. Between turns the method\n * appends the change immediately because no in-turn pre-step will run until\n * another prompt starts a turn. The open-turn fold is the idle signal:\n * agent status stays `running` through post-turn checkpointing, when no\n * further in-turn pre-step runs. During an open turn the selection remains\n * pending until the next accepted in-turn pre-step. Repeated selection of\n * the current or already-pending state is a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n * @returns what happened: `committed` (logged now), `queued` (awaiting the\n * next accepted in-turn pre-step), `cancelled` (an opposite pending selection\n * was cleared; the logged state already matches), or `noop` (already in that\n * state).\n */', }, ], }, @@ -746,7 +746,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'register<K extends keyof SessionProjectionMap, S>(definition: ProjectionDefinition<K, S>): () => void', - jsDoc: '/**\n * Register one domain\'s unit. The registration is an effect on the calling\n * context\'s fiber: disposing the fiber (or calling the returned disposer)\n * removes the key — and the unit\'s cached cells — from subsequent drives\n * and snapshots.\n * @param definition - key, boundary schema, pure unit functions, and stateVersion.\n * @returns the exact disposer that unregisters this unit.\n */', + jsDoc: '/**\n * Register one domain\'s unit. The registration is an effect on the calling\n * context\'s fiber: disposing the fiber (or calling the returned disposer)\n * removes the key — and the unit\'s cached cells — from subsequent drives\n * and snapshots.\n * @param definition - key, state schema, pure unit functions, and stateVersion.\n * @returns the exact disposer that unregisters this unit.\n */', }, { signature: 'onChanged(listener: ProjectionChangeListener): () => void', @@ -820,11 +820,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot>', - jsDoc: '/**\n * Read one session\'s complete current model surface from one corpus observation.\n * @param sessionId - live-preferred session id to read.\n * @returns cloned header, current surface, and raw-log capture boundary.\n * @throws when source resolution fails or the session surface is invalid.\n */', + jsDoc: '/**\n * Read one session\'s complete current model surface from one corpus observation.\n * @param sessionId - live-preferred session id to read.\n * @returns cloned header, current surface, and the last sequence number included in the raw-log capture.\n * @throws when source resolution fails or the session surface is invalid.\n */', }, { signature: 'async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace>', - jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @param signal - optional cancellation for persistence listing.\n * @returns a complete lineage or an explicit unresolved parent boundary.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */', + jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @param signal - optional cancellation for persistence listing.\n * @returns a complete lineage or the first parent that could not be resolved.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */', }, { signature: 'async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise<SessionEventTraceObservation>', @@ -1150,7 +1150,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'telemetry', - summary: 'The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis\' standard behavior.', + summary: 'Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis\' standard behavior.', methods: [ { signature: 'abstract emit(record: TelemetryRecord): void', @@ -1308,7 +1308,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>', - jsDoc: '/**\n * Run one search through the selected provider. Resolves the provider at call\n * time with the selection rules above; throws {@link WebError} when the\n * capability cannot run. The seam enforces `request.maxResults` on the result:\n * if the provider over-returns, `sources[]` is truncated and `truncated` set.\n * @param request - the query plus result-shaping options.\n * @param signal - optional cancellation signal forwarded to the provider.\n * @returns the provider\'s results, capped to `request.maxResults`.\n */', + jsDoc: '/**\n * Run one search through the selected provider. Resolves the provider at call\n * time with the selection rules above; throws {@link WebError} when the\n * capability cannot run. The seam enforces `request.maxResults` on the result:\n * if the provider over-returns, `sources[]` is truncated and `truncated` set.\n * @param request - the query and optional result limit.\n * @param signal - optional cancellation signal forwarded to the provider.\n * @returns the provider\'s results, capped to `request.maxResults`.\n */', }, { signature: 'async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>', @@ -1630,8 +1630,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'tools/code-dispatch-log', mode: 'waterfall', signature: '\'tools/code-dispatch-log\'(this: Scoped<ToolRegistry>, dispatch: CodeDispatchLog, next: () => Promise<ContentBlock[]>): Promise<ContentBlock[]>', - jsDoc: '/**\n * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before\n * the bridge appends its `tool/code-dispatch` event. `next()` keeps the\n * content unchanged; a listener may return replacement blocks (e.g. the\n * spill policy\'s preview + locator for an oversized text result). Only the\n * logged copy is affected — the program already received the complete\n * value, and the model sees neither. A throwing listener is contained:\n * the bridge falls back to logging the unshaped content.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s dispatches.\n * @param dispatch - the parent execution, sub-call identity, and the settled content to log.\n * @mode waterfall\n */', - summary: 'Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event.', + jsDoc: '/**\n * Allow a listener to replace content in the DURABLE LOG COPY of one\n * `run_code` sub-dispatch outcome before the bridge appends its\n * `tool/code-dispatch` event. `next()` keeps the\n * content unchanged; a listener may return replacement blocks (e.g. the\n * spill policy\'s preview + locator for an oversized text result). Only the\n * logged copy is affected — the program already received the complete\n * value, and the model sees neither. A throwing listener is contained:\n * the bridge falls back to logging the original settled content.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s dispatches.\n * @param dispatch - the parent execution, sub-call identity, and the settled content to log.\n * @mode waterfall\n */', + summary: 'Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event.', }, { name: 'tools/execute', diff --git a/packages/self-modification/tool-cordis/src/sandbox.ts b/packages/self-modification/tool-cordis/src/sandbox.ts index 6b3e20a82c..f093da3e24 100644 --- a/packages/self-modification/tool-cordis/src/sandbox.ts +++ b/packages/self-modification/tool-cordis/src/sandbox.ts @@ -56,8 +56,8 @@ const TIMER_REDIRECT /** * The callable Node APIs the sandbox deliberately disables, each mapped to the - * cordis alternative its trap error names. Only FUNCTION-shaped globals are - * trapped — a data-shaped global like `process` stays `undefined`, because a + * cordis alternative its trap error names. Only function-valued globals are + * trapped; a data-valued global such as `process` stays `undefined`, because a * throwing accessor would detonate the common `typeof process` feature probe * at resolution time. */ diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index b891f5750d..919bf00c88 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -257,7 +257,7 @@ export abstract class SessionQueryService extends Service { /** * Read one session's complete current model surface from one corpus observation. * @param sessionId - live-preferred session id to read. - * @returns cloned header, current surface, and raw-log capture boundary. + * @returns cloned header, current surface, and the last sequence number included in the raw-log capture. * @throws when source resolution fails or the session surface is invalid. */ async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot> { @@ -273,7 +273,7 @@ export abstract class SessionQueryService extends Service { * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. * @param signal - optional cancellation for persistence listing. - * @returns a complete lineage or an explicit unresolved parent boundary. + * @returns a complete lineage or the first parent that could not be resolved. * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. */ async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace> { diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index fd62306b1a..809982f94d 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -211,8 +211,8 @@ export function logPath( * `packChunks` on, delta-chunk runs pack into `text-chunks` / * `reasoning-chunks` / `tool-call-chunks` storage rows; off writes one event * per line, byte-identical to the pre-packing layout. Reading is layout-blind - * either way ({@link scanLog} always decodes rows), so the switch only shapes - * NEW bytes. + * either way ({@link scanLog} always decodes rows), so the switch changes only + * newly written bytes. * @param events - the batch to serialize, in log order. * @param packChunks - whether to pack delta runs into storage rows. * @returns the batch's JSONL text; the writer adds the final newline. diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index 74a0d4c8a9..15808bb5e4 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md -README.md: c64826db1e9c7339f43a64ad7c13f01a2a39638e -README.zh.md: 651d920404300fa602f77cee63b0f31aec837919 +README.md: 391548b1b896dca14cbe4f4ae55cf4180c4e0ac2 +README.zh.md: 7213e1ee71ba418ffacc3685df371dcba33588a7 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index c64826db1e..391548b1b8 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The durable session-persistence Service Definition (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a Service provider in a sibling package, and Consumers that inject the service. +Session persistence is a capability seam. The abstract `SessionPersistence` service (`ctx.sessionPersistence`) is its Service Definition. It requires a persistence backend to store, reload, and list sessions durably without defining the storage implementation. The seam follows the `dsh-bash` roles ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): this package owns the Service Definition, a sibling package owns the Service provider, and Consumers inject the service. The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage, seed boundary, origin, delegation depth) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here. @@ -14,9 +14,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise<void>` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `prepare(id, signal?): Promise<SessionPreparation>` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. | -| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after supported same-version shape upgrades and commit cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed shapes, and unknown `version` reject. | +| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed records, and unknown `version` reject. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The detached physical-suffix primitive: return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless a supported old shape requires prefix context for normalization; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that fold only the tail past a watermark. | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that apply only events after a stored sequence number. | | `list(signal?): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | @@ -35,7 +35,7 @@ Each `session/event` copies its event into the session controller. The first pen Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. -Backend reads normalize the exact supported same-version shapes before current-shape validation. Pre-identity messages receive the deterministic id `legacy-message:<session-id>:<event-seq>`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing a caller that the old record did not name. The coordinator uses the same normalized view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current shape. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise. +Backend reads convert the exact supported older records from the same format version before validating current records. Pre-identity messages receive the deterministic id `legacy-message:<session-id>:<event-seq>`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing a caller that the old record did not name. The coordinator uses the same converted view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current format. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise. When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 651d920404..7213e1ee71 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这是用于持久保存会话的 Service Definition(`ctx.sessionPersistence`)。它定义持久化后端做什么:持久存储、重新加载和列出会话,而不规定如何实现。它与 `dsh-bash` 能力 seam 模板一致(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包提供抽象服务,同级包提供 Service provider,Consumer 注入服务。 +会话持久化是一项能力 seam。抽象的 `SessionPersistence` 服务(`ctx.sessionPersistence`)是其 Service Definition。它要求持久化后端持久存储、重新加载和列出会话,但不规定具体存储实现。该 seam 采用与 `dsh-bash` 相同的角色划分(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包负责 Service Definition,同级包负责 Service provider,Consumer 注入该服务。 持久化单元就是现有 `SessionEvent`(事件溯源模型:日志是唯一真源),因此不存在另一套并行的「持久消息」类型。不属于可回放对话状态的元数据(格式版本、cwd、血缘、种子边界、origin、委托深度)作为 `SessionHeader` 单独传输,该类型归 `dsh-session` 所有,并在此重新导出。 @@ -14,9 +14,9 @@ | `create(meta): Promise<void>` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | | `append(id, events): Promise<void>` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | | `prepare(id, signal?): Promise<SessionPreparation>` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose 时将未发布 reservation 释放回有界缓存。 | -| `load(id): Promise<{ meta; events }>` | 在升级受支持的同版本形状后返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的形状和未知 `version` 会被拒绝。 | +| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的记录和未知 `version` 会被拒绝。 | | `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 脱离的物理后缀原语:返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非受支持的旧形状需要前缀上下文才能完成规范化;顺序后端(JSONL)解析整个产物并向前跳过。用于只续折水位之后尾部的 checkpoint 消费方。 | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。供 checkpoint 消费方只应用已存序号之后的事件。 | | `list(signal?): Promise<SessionHeader[]>` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 | @@ -35,7 +35,7 @@ 崩溃修复只适用于冷状态。对于实时 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。对于冷 id,检查只读取、验证、冻结并构造一次未发布 Session;只有来源修订值仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留该 Session 本身,提交任何待处理的撕裂尾部或中断轮次修复,并将其返回用于发布。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。 -后端读取会在当前形状验证前,规范化明确受支持的同版本形状。消息标识机制引入前的消息会获得确定性的 id `legacy-message:<session-id>:<event-seq>`;工具结果的内容替换会继承其目标导入后的 id。react-loop 引入前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会在不虚构旧记录中未命名调用方的前提下映射终止原因。协调器对 `load`、`inspect`、`readFrom`、无所有者状态的认领和 HMR 前缀接管使用同一份规范化视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前形状。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 +后端读取会在验证当前记录前,转换同一格式版本中明确受支持的旧记录。消息标识机制引入前的消息会获得确定性的 id `legacy-message:<session-id>:<event-seq>`;工具结果的内容替换会继承其目标导入后的 id。react-loop 引入前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会映射终止原因,但不会虚构旧记录中没有记载的调用方。协调器对 `load`、`inspect`、`readFrom`、无所有者状态的认领和 HMR 前缀接管使用同一份转换后视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前格式。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 实时会话发出 `session/disposed` 时,协调器等待其 controller,串行化最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在实时会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 1d0364d1f2..154a5ca2fa 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -65,7 +65,7 @@ export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> { */ view(state: S): SessionProjectionMap[K] /** - * Persisted-cache invalidation anchor: bump whenever the state shape or the + * Persisted-cache invalidation version: bump whenever the serialized state fields or the * fold semantics change, so persisted `(sessionId, key, ver, seq, val)` * rows from an older unit are discarded instead of being forward-applied * into garbage. Non-negative integer. @@ -188,7 +188,7 @@ export class SessionProjectionRegistry extends Service { * context's fiber: disposing the fiber (or calling the returned disposer) * removes the key — and the unit's cached cells — from subsequent drives * and snapshots. - * @param definition - key, boundary schema, pure unit functions, and stateVersion. + * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ register<K extends keyof SessionProjectionMap, S>(definition: ProjectionDefinition<K, S>): () => void { diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index b66d641750..50776f7d3f 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -3,9 +3,8 @@ * * Composes the OTel JS SDK as-is — a `LoggerProvider` with a * `BatchLogRecordProcessor` and an OTLP/HTTP log exporter — and maps each - * record handed over by the capture coordinator onto `logger.emit()`. Per the Service Definition's - * boundary axiom, everything downstream of that call (batching, retry, - * queueing, loss policy) is the SDK's documented behavior, configured + * record handed over by the capture coordinator onto `logger.emit()`. After that call, + * batching, retry, queueing, and loss policy use the SDK's documented behavior, configured * verbatim through the `exporter`/`processor` passthroughs. This package owns * capture mode and an outer shutdown deadline: the SDK's export timeout does * not bound its preceding `forceFlush()` wait. @@ -73,7 +72,7 @@ function assertNever(value: never): never { } /** - * Plugin configuration: one sharing policy, two verbatim SDK option shapes, + * Plugin configuration: one sharing policy, two verbatim SDK option objects, * and one DSH-owned shutdown bound. Uploading modes validate their endpoint * and shutdown deadline at plugin load; `DISABLED` reads neither. */ @@ -101,11 +100,10 @@ export interface Config { /** * Schemastery validator for {@link Config}; cordis runs it before the plugin - * starts. Shape-level only — load-bearing value checks live in the constructor - * so their errors name the fields. Both SDK slots are opaque passthroughs: - * the SDK owns their shapes and validates its own options; - * re-declaring them field-by-field here would violate the boundary axiom - * (and silently drop every field not re-declared). + * starts. It checks only the top-level fields; value checks live in the constructor + * so their errors name the fields. Both SDK option objects pass through unchanged: + * the SDK defines and validates their fields. Re-declaring them here would + * silently drop every field this plugin did not repeat. */ export const Config: z<Config> = z.object({ mode: z.union(Object.values(TelemetryMode)).default(DEFAULT_TELEMETRY_MODE), diff --git a/packages/session/session-telemetry/README.i18n.yaml b/packages/session/session-telemetry/README.i18n.yaml index c6c4b9eff9..3d4650361f 100644 --- a/packages/session/session-telemetry/README.i18n.yaml +++ b/packages/session/session-telemetry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-telemetry/README.md -README.md: 506f0f5bcb03a54f09805ed0f866a396e3cf0334 -README.zh.md: b5e47c8832452ece281dcdcfac007d6082813583 +README.md: 827554dd53a81eab5a5fd7f145df3f835db9c173 +README.zh.md: a350ea5935a2143cb0f876eeb1eb0520ffee5c53 diff --git a/packages/session/session-telemetry/README.md b/packages/session/session-telemetry/README.md index 506f0f5bcb..827554dd53 100644 --- a/packages/session/session-telemetry/README.md +++ b/packages/session/session-telemetry/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -The telemetry Service Definition and capture coordinator sit behind a backend contract any reporting SDK satisfies with zero bending. Capture can follow live session events or replay a canonical session-log prefix on demand. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md), [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md), and [buffer-free feedback replay](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md). +The telemetry Service Definition declares the `TelemetryBackend` contract, and its capture coordinator passes session records to any reporting SDK backend that implements it. Capture can follow live session events or replay a canonical session-log prefix on demand. This package stops after it calls `emit()`: batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md), [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md), and [buffer-free feedback replay](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md). ## The backend contract -`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path or during an explicit canonical-log replay), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` with `live` capture or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its owning trigger. +`TelemetryBackend` has three members: `emit(record)` MUST enqueue without blocking because it runs synchronously during `session/event` or explicit canonical-log replay; optional `flush()` is a fire-and-forget hint after a turn ends, and most backends omit it and use their SDK's normal batching schedule; `shutdown()` drains queued records and resolves when the SDK stops, and disposal awaits it. An implementation that provides `flush()` must order concurrent flushes with the final `shutdown()` drain. `Telemetry` registers this API under the `telemetry` context key; each context accepts one implementation, and a duplicate load throws. A backend constructs `TelemetryCoordinator` with `live` or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its chosen trigger. ## Capture points diff --git a/packages/session/session-telemetry/README.zh.md b/packages/session/session-telemetry/README.zh.md index b5e47c8832..a350ea5935 100644 --- a/packages/session/session-telemetry/README.zh.md +++ b/packages/session/session-telemetry/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -遥测(telemetry)Service Definition 与捕获协调器位于一个后端约定之后,任何上报 SDK 都无需变形即可满足该约定。捕获侧可跟随实时会话事件,也可按需回放权威会话日志前缀。塑造本包(package)一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md)。 +遥测(telemetry)Service Definition 声明 `TelemetryBackend` 后端约定,捕获协调器把会话记录传给实现该约定的任意上报 SDK 后端。捕获侧可跟随实时会话事件,也可按需回放权威会话日志前缀。本包调用 `emit()` 后就停止处理:批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不规定也不包装。设计依据与被否决的替代方案见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md)。 ## 后端约定 -`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径或显式权威日志回放期间同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 模式组合 `TelemetryCoordinator`,并在自身所属的触发器中调用 `captureSession(session, throughSeq?)`。 +`TelemetryBackend` 有三个成员:`emit(record)` 必须入队且不能阻塞,因为它会在 `session/event` 或显式权威日志回放期间同步执行;可选的 `flush()` 是轮次结束后的提示,调用方不等待结果,多数后端省略它并使用 SDK 的常规批处理计划;`shutdown()` 排空已入队记录,并在 SDK 停止后结束,dispose(资源释放)会等待它。提供 `flush()` 的实现必须安排并发 flush 与 `shutdown()` 最终排空的先后顺序。`Telemetry` 将此 API 注册在 `telemetry` 上下文键下:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 捕获构造 `TelemetryCoordinator`,并在自己选择的触发器中调用 `captureSession(session, throughSeq?)`。 ## 捕获点 diff --git a/packages/session/session-telemetry/src/index.ts b/packages/session/session-telemetry/src/index.ts index 977527aace..7ddd85fe8e 100644 --- a/packages/session/session-telemetry/src/index.ts +++ b/packages/session/session-telemetry/src/index.ts @@ -87,9 +87,8 @@ export interface TelemetryRecord { } /** - * The backend contract the coordinator hands records to — the minimum any - * reporting SDK satisfies with zero bending. {@link Telemetry} is its - * service-registered form; tests compose the coordinator with a bare + * The minimum backend contract the coordinator requires. {@link Telemetry} is + * its service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ export interface TelemetryBackend { @@ -104,8 +103,8 @@ export interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a natural boundary (turn end) passed — a backend may - * forward it to its SDK's flush so records land at turn boundaries. Called + * Optional hint that a turn ended. A backend may forward it to its SDK's + * flush so records are exported after each turn. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -132,7 +131,7 @@ export interface TelemetryBackend { } /** - * The backend contract in its loadable form: one implementation per context — + * Loadable form of the backend contract: one implementation per context — * the cordis `Service` registration under the `telemetry` key throws on a * duplicate, cordis' standard behavior. A backend composes a * {@link TelemetryCoordinator} in its constructor to install the capture side. diff --git a/packages/settings/settings/README.i18n.yaml b/packages/settings/settings/README.i18n.yaml index 4452e78dab..fba3913f75 100644 --- a/packages/settings/settings/README.i18n.yaml +++ b/packages/settings/settings/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/settings/settings/README.md -README.md: 5bfbf4623c937c2b66886f71adf27075523f5d28 -README.zh.md: 98808424ba1af2210067bd7f74baa6027decbb06 +README.md: 7917f38017bfb23dc4718ee533c1f9a92b519d41 +README.zh.md: f46cf433b4d2207b17b0f40cc3b9cf70794b512c diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md index 5bfbf4623c..7917f38017 100644 --- a/packages/settings/settings/README.md +++ b/packages/settings/settings/README.md @@ -11,7 +11,7 @@ User-settings Service Definition (`ctx.settings`). One provider holds a raw docu - `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud. - `describe(options?)` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, detached `base`/`user` layers, `applies`) for configuration surfaces; a field's presence in `user` is what marks it user-overridden. `describe({ redactSecrets: true })` strips `role('secret')` fields from every layer and adds the `secrets` slot list (`{ path, set }`); every wire surface MUST pass it, and the pure `redactSecrets(schema, value)` walker is exported for other wires. - `get(ns)` — resolved value, `undefined` while unregistered. -- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches must be JSON-shaped data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently distort such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order. +- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches may contain only JSON-compatible data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently change such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order. - `replace(ns, section)` — sets the user section wholesale: the deliberate reset (`replace({})` re-inherits `base` and schema defaults). - `mutate(ns, ops)` — applies ordered `{ op: 'set' | 'unset', path }` edits to the section as it stands when the write reaches the front of the queue. This is the removal path for any caller holding an INCOMPLETE view: a configuration UI reads the redacted descriptor, so rebuilding a section from it and replacing wholesale deletes every secret the wire never returned, while an op names the one field it means. - Every write takes an optional `expectedRevision`. Each descriptor carries the namespace's `revision`, a monotonic counter over its RAW section; a write whose expectation no longer matches rejects with `SettingsConflictError` (`code: 'SETTINGS_CONFLICT'`, both revisions attached) instead of overwriting the writer that landed first. The write queue orders writes but cannot by itself tell a fresh writer from one holding a stale snapshot. diff --git a/packages/settings/settings/README.zh.md b/packages/settings/settings/README.zh.md index 98808424ba..f46cf433b4 100644 --- a/packages/settings/settings/README.zh.md +++ b/packages/settings/settings/README.zh.md @@ -11,7 +11,7 @@ - `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。 - `describe(options?)` — 每个 namespace 一条描述(`schema.toJSON()` 封装、解析值、分离出的 `base`/`user` 层、`applies`),供配置界面使用;字段出现在 `user` 中即标记其被用户覆盖。`describe({ redactSecrets: true })` 从每一层剥离 `role('secret')` 字段,并附加 `secrets` 槽位列表(`{ path, set }`);每个协议接口都必须传入它,纯遍历器 `redactSecrets(schema, value)` 已导出,供其他 wire 使用。 - `get(ns)` — 解析值;未注册时为 `undefined`。 -- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经提供方持久化后提交。patch 必须是 JSON 形状的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默扭曲这类值)。校验失败在持久化前拒绝;只读提供方(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。 +- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经提供方持久化后提交。patch 只能包含与 JSON 兼容的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默改变这类值)。校验失败在持久化前拒绝;只读提供方(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。 - `replace(ns, section)` — 整体替换用户分节:这是刻意的重置(`replace({})` 重新继承 `base` 与 schema 默认值)。 - `mutate(ns, ops)` — 在写入排到队首那一刻的分节上,按序施加 `{ op: 'set' | 'unset', path }` 编辑。这是任何持有**不完整**视图的调用方的删除路径:配置 UI 读到的是脱敏后的 descriptor,据此重建分节再整体替换,会把 wire 从未回传的每个机密都删掉,而一条 op 只点名它真正要改的那个字段。 - 每次写入都可携带可选的 `expectedRevision`。每个 descriptor 都带有该 namespace 的 `revision`——一个针对其**原始**分节的单调计数器;期望值不再匹配的写入会以 `SettingsConflictError`(`code: 'SETTINGS_CONFLICT'`,并附上两个 revision)被拒绝,而不是覆盖先完成写入的写入方。写队列只保证写入的先后次序,它本身分辨不出新的写入方与持有陈旧快照的写入方。 diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index d0f85a2b1b..37d3ec1d50 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -120,14 +120,14 @@ export interface SettingsScope<T> { watch(callback: (next: T, prev: T) => void | Promise<void>): () => void /** * Merge a partial patch into this namespace's user layer and persist it. - * @param patch - plain-object patch over the user section; JSON-shaped data + * @param patch - plain-object patch over the user section; JSON-compatible data * only (non-JSON values reject with their path before anything persists). */ update(patch: object): Promise<void> /** * Replace this namespace's user section wholesale; absent keys re-inherit * the composition `base` and schema defaults (`replace({})` resets all). - * @param section - the complete next user section; JSON-shaped data only, + * @param section - the complete next user section; JSON-compatible data only, * as for {@link update}. */ replace(section: object): Promise<void> @@ -172,11 +172,11 @@ declare module 'cordis' { } /** - * Deep equality over JSON-shaped data (objects, arrays, primitives) — the + * Deep equality over JSON-compatible data (objects, arrays, primitives) — the * Service Definition's single change-detection predicate, exported so the invariant * companion checks exactly the implementation's relation. - * @param a - one JSON-shaped value. - * @param b - the other JSON-shaped value. + * @param a - one JSON-compatible value. + * @param b - the other JSON-compatible value. * @returns whether the two values are structurally equal. */ export function deepEqualJson(a: unknown, b: unknown): boolean { @@ -264,7 +264,7 @@ function applyPathOp(section: Record<string, unknown>, op: SettingsPathOp): Reco return { ...section, [head]: applyPathOp(child, { ...op, path: rest }) } } -/** Human label for a value rejected by the JSON-shape boundary (numbers reject inline). */ +/** Human label for a value that lossless JSON cannot represent (numbers reject inline). */ function describeRejected(value: unknown): string { if (value === undefined) return 'undefined' if (typeof value === 'object' && value !== null) { @@ -276,16 +276,16 @@ function describeRejected(value: unknown): string { } /** - * Detach one write input in a single walk that doubles as the durable-boundary - * shape check: only JSON data (plain objects, arrays, strings, finite numbers, + * Detach and validate one write input in a single walk before persistence: + * only JSON data (plain objects, arrays, strings, finite numbers, * booleans, `null`) may reach a provider document. `structuredClone` alone * would admit Dates, Maps, BigInts, and cycles that YAML/JSON storage then * silently distorts on the reload round-trip. `undefined` entries in objects * are skipped — the same sparse-patch semantics as {@link mergeLayers} — while * an `undefined` array entry is rejected rather than coerced. * @param root - plain-object write input (caller-checked). - * @param reject - builds the boundary error from a value label and its `$`-rooted path. - * @returns the detached JSON-shaped clone. + * @param reject - builds the validation error from a value label and its `$`-rooted path. + * @returns the detached JSON-compatible clone. */ function cloneJsonShaped( root: Record<string, unknown>, @@ -640,9 +640,9 @@ export abstract class Settings extends Service { } // Snapshot at call time: the queue must never read a caller-owned object // the caller may keep mutating while the write waits its turn. The same - // walk is the JSON-shape boundary check (see cloneJsonShaped). + // walk rejects values that JSON cannot preserve (see cloneJsonShaped). const snapshot = cloneJsonShaped(payload, (label, path) => - new TypeError(`settings ${verb} for "${ns}" must be JSON-shaped data (found ${label} at ${path})`)) + new TypeError(`settings ${verb} for "${ns}" must contain only JSON-compatible data (found ${label} at ${path})`)) const previous = this.writeQueues.get(ns) ?? Promise.resolve() // Chain past a failed predecessor: one rejected write must not poison the // namespace queue for every later caller. diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index 66c0f1fcf6..fb0efe1ff8 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -479,11 +479,11 @@ describe('second review regressions', () => { expect(applied).toEqual([1, 2]) }) - it('rejects a function value as not JSON-shaped', async () => { + it('rejects a function value as not JSON-compatible', async () => { const { ctx } = await boot() const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) await expect(scope.update({ theme: () => 'dark' })) - .rejects.toThrow(/JSON-shaped.*function at \$\.theme/) + .rejects.toThrow(/JSON-compatible.*function at \$\.theme/) }) it('rejects a write still queued when the service disposes', async () => { @@ -616,7 +616,7 @@ describe('third review regressions', () => { const { ctx, provider } = await boot() const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() })) await expect(scope.update({ value: { at: new Date(0) } })) - .rejects.toThrow(/JSON-shaped.*Date at \$\.value\.at/) + .rejects.toThrow(/JSON-compatible.*Date at \$\.value\.at/) expect(provider.persisted).toEqual([]) }) @@ -914,10 +914,10 @@ describe('mutate (path-addressed writes)', () => { expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user).toEqual({ apiKey: 'sk-stored' }) }) - it('rejects a value the JSON-shape boundary refuses', async () => { + it('rejects a value that lossless JSON cannot represent', async () => { const ctx = await mounted({ keyed: {} }) await expect(ctx.settings.mutate(KEYED, [{ op: 'set', path: ['baseURL'], value: new Date() }])) - .rejects.toThrow(/must be JSON-shaped data/) + .rejects.toThrow(/must contain only JSON-compatible data/) }) }) diff --git a/packages/storage/storage-domain/src/spec.ts b/packages/storage/storage-domain/src/spec.ts index 9e49ef41e3..bf5de09a29 100644 --- a/packages/storage/storage-domain/src/spec.ts +++ b/packages/storage/storage-domain/src/spec.ts @@ -65,7 +65,7 @@ export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTabl } /** - * Identity helper that pins a spec's literal types and validates its shape. + * Identity helper that pins a spec's literal types and validates its fields. * Misconfiguration fails loud at the owning package's module load, before any * medium is touched: a domain or table name outside `UNIT_NAME_RE`, a version * that is not a non-negative integer, or a global schema that accepts `null` diff --git a/packages/storage/storage/src/backend.ts b/packages/storage/storage/src/backend.ts index d9070874ca..52a09a0185 100644 --- a/packages/storage/storage/src/backend.ts +++ b/packages/storage/storage/src/backend.ts @@ -1,21 +1,21 @@ /** * Backend-facing vocabulary of the storage hub: a backend owns one medium - * (a file-tree root, a database file) and exposes data-shape facets over it. - * This module is the normative contract text for backend implementers; the - * shared conformance suite in `tests/contract.ts` asserts every clause. + * (a file-tree root, a database file) and exposes operation groups over it. + * This module defines the normative contract text for backend implementers; the shared + * conformance suite in `tests/contract.ts` checks every rule. * @module @deepseek-ai/dsh-storage/src/backend */ -/** Allowed shape for unit and table names: safe as a file name and as a SQL identifier segment without escaping. */ +/** Allowed format for unit and table names: safe as a file name and as a SQL identifier segment without escaping. */ export const UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/ /** * One registered backend. A backend owns exactly one medium and shares its * lifecycle across all facets; facets are optional members — a backend that - * cannot serve a shape simply omits it, and resolution fails loud instead. + * cannot serve a data kind simply omits it, and resolution fails loud instead. */ export interface StorageBackend { - /** Key-value data shape; absent when this backend cannot serve it. */ + /** Key-value operations; absent when this backend cannot serve them. */ readonly kv?: KvFacet /** diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 01daa65f77..193e3b8dea 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/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-acp/README.md -README.md: 33711eb93c38d93472b3b625a86721c1365ac8d9 -README.zh.md: e57010f395e4ebae9b2e909bf63b7ffa1a90afe2 +README.md: 3bccddbca021bed1f8bf5766b9575f3bd7441669 +README.zh.md: 80afd65e5f4815042f05c22e4597bbb2677fb4fc diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 33711eb93c..3bccddbca0 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -28,7 +28,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th | `command` | required | Executable spawned for each run. | | `args` | `[]` | Command arguments. | | `cwd` | parent session cwd | Working-directory override for the child process and its ACP session; must be non-empty, a relative value resolves against the harness launch directory at load, and the result must name a directory the harness can enter. | -| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. | +| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first `allow_once` or `allow_always` option. | | `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. | | `disposeEofGraceMs` | `6000` | Positive grace after stdin EOF before platform termination; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | | `disposeGraceMs` | `3000` | Positive POSIX grace after SIGTERM before SIGKILL (Windows force-terminates directly); it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index e57010f395..80afd65e5f 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -28,7 +28,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 | `command` | 必填 | 每次运行时 spawn 的可执行文件。 | | `args` | `[]` | 命令参数。 | | `cwd` | 父会话 cwd | 子进程及其 ACP 会话的工作目录覆盖值;不得为空。相对值会在加载时以 harness 启动目录为基准解析,结果必须指向 harness 可以进入的目录。 | -| `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个允许形态的选项。 | +| `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个 `allow_once` 或 `allow_always` 选项。 | | `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 | | `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | | `disposeGraceMs` | `3000` | POSIX 在 SIGTERM 后、SIGKILL 前的宽限时间(Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 7cc781ca15..af126b86de 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -42,7 +42,7 @@ export interface Config { /** * How to auto-answer the child's `session/request_permission` prompts: * `reject` (default — decline every prompt) or `allow` (approve via the first - * allow-shaped option). No prompt is surfaced to a human. + * `allow_once` or `allow_always` option). No prompt is surfaced to a human. */ permission: PermissionPolicy /** diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 17476be41c..7c82bfe9fb 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -248,8 +248,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe return Promise.resolve() }, requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> { - // Auto-answer by the configured policy. `allow` selects the first - // allow-shaped option the child offered; if it offered none (or we + // Auto-answer by the configured policy. `allow` selects the first option + // whose kind is `allow_once` or `allow_always`; if the child offered none (or we // reject), answer `cancelled` so the child does not proceed. if (spec.permission === 'allow') { const allow = params.options.find(o => o.kind === 'allow_once' || o.kind === 'allow_always') diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index b1df485644..de2fd37115 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -219,8 +219,9 @@ export interface SubagentResult { * The structured result after a requested `outputSchema` was successfully * satisfied. Requesting a schema does not guarantee presence: a provider can * end with `stopReason: 'error'` when the child fails or finishes without a - * valid capture. Shape is validated against the request schema by the - * provider; `unknown` here because the seam is schema-agnostic. + * valid capture. The structured value is validated against the requested + * output schema by the provider; `unknown` here because the seam is + * schema-agnostic. */ readonly structured?: unknown /** Why the run ended. A non-`completed` reason means `output` may be partial. */ diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index c0cfc56e24..5cdffadf38 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -436,7 +436,7 @@ export function formatToolSchemasSnapshot(initial: readonly unknown[], changes: } /** - * Parse and validate the stable top-level shape of a tool-schema sidecar. + * Parse and validate the stable top-level fields of a tool-schema sidecar. * * @param snapshot The JSON sidecar text. * @returns Its initial and changed-header schema sets. diff --git a/packages/support/invariants/README.i18n.yaml b/packages/support/invariants/README.i18n.yaml index 9d85ffd0a5..ea7fbba8f3 100644 --- a/packages/support/invariants/README.i18n.yaml +++ b/packages/support/invariants/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/invariants/README.md -README.md: 71f7fb8b913610aad03694effad7746c0cb16499 -README.zh.md: ce2fc593a294a7f870bb42faa980fab462348e3b +README.md: 9a93187032b6f8e4f5d89e17e742baba41196ff9 +README.zh.md: 7f3fa1e23337e55a73928c3952aa4c925a5fb4e9 diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 71f7fb8b91..9a93187032 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -30,7 +30,7 @@ Session itself owns immutable, surface-valid log storage in every composition: i Publication and registration are exhaustive; runtime assertions are deliberately not synthetic. A companion installs a check only when its package owns an observable event relationship or relevant mutable-data relationship. Confirming a required method, plugin name, injection, effect, or fixed pure-function result is a type, load, or unit-test concern rather than a runtime invariant. -When no plausible runtime relationship exists, the companion uses an empty installer with a package-specific leading `No runtime invariant:` comment explaining why. This is common for pure utilities, thin implementations whose behavior is already observed through their seam, composition-only packages, binaries, persistence adapters whose contracts require crash/round-trip tests, and test-support packages. The explanation must be revisited when the owner gains mutable state or an event protocol. +When no plausible runtime relationship exists, the companion uses an empty installer with a package-specific leading `No runtime invariant:` comment explaining why. This is common for pure utilities, thin implementations whose behavior is already observed through their interface package, composition-only packages, binaries, persistence adapters whose contracts require crash and round-trip tests, and test-support packages. The explanation must be revisited when the owner gains mutable state or an event protocol. The current executable companions protect these relationships: @@ -66,7 +66,7 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -The standard agent spine mounts the service and its four core stateful companions. Custom compositions explicitly add companions for other loaded packages whose contracts they want checked; filters can disable or select registrations without changing package entrypoints. +The standard agent composition mounts the service and its four core stateful companions. Custom compositions explicitly add companions for other loaded packages whose contracts they want checked; filters can disable or select registrations without changing package entrypoints. Every ordinary Vitest topology mounts an explicitly enabled service and the current test package's companion. Focused suites cover valid and invalid observations for executable companions, while one exhaustive topology mounts all companions to prove registration and disposal wiring. diff --git a/packages/support/invariants/README.zh.md b/packages/support/invariants/README.zh.md index ce2fc593a2..7f3fa1e233 100644 --- a/packages/support/invariants/README.zh.md +++ b/packages/support/invariants/README.zh.md @@ -30,7 +30,7 @@ interface Config { 发布和注册覆盖全部包;但不会为了覆盖全部包而人为编造运行时断言。只有当包拥有可观察事件关系或相关可变数据关系时,配套入口才安装检查。确认必需方法、插件名称、注入、effect 或固定纯函数结果属于类型、加载或单元测试关注点,而非运行时不变量。 -如果不存在合理的运行时关系,配套入口使用空 installer,并以包专用的前置 `No runtime invariant:` 注释说明原因。纯工具、行为已通过 seam 观察的薄实现、仅组合包、二进制程序、约定需要崩溃/往返测试的持久化适配器和测试支持包通常属于此类。当 owner 获得可变状态或事件协议时,必须重新审视该说明。 +如果不存在合理的运行时关系,配套入口使用空 installer,并以包专用的前置 `No runtime invariant:` 注释说明原因。纯工具、行为已通过其接口包观察的薄实现、仅组合包、二进制程序、需要通过崩溃测试和往返测试验证其约定的持久化适配器和测试支持包通常属于此类。当 owner 获得可变状态或事件协议时,必须重新审视该说明。 当前可执行配套入口保护以下关系: @@ -66,7 +66,7 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -标准 agent 主干挂载服务和 4 个核心有状态配套入口。自定义组合为希望检查其约定的其他已加载包显式添加配套入口;过滤器可以在不改变包入口的情况下禁用或选择注册。 +标准 agent 组合挂载服务和 4 个核心有状态配套入口。自定义组合为希望检查其约定的其他已加载包显式添加配套入口;过滤器可以在不改变包入口的情况下禁用或选择注册。 每个普通 Vitest 拓扑都挂载显式启用的服务和当前测试包的配套入口。聚焦套件覆盖可执行配套入口的合法与违规观测,一个穷尽拓扑则挂载全部配套入口,以证明注册和 dispose 接线。 diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 9af72bbbec..9f18c18271 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -281,7 +281,7 @@ const REPLAY_CHUNK_TYPES = new Set<StreamChunk['type']>([ const FROM_REQUEST_OPEN = '{{fromRequest:' const FROM_REQUEST_CLOSE = '}}' -/** Collect every string leaf of one JSON-shaped value, in traversal order. */ +/** Collect every string leaf of one JSON-compatible value, in traversal order. */ function collectStrings(value: unknown, out: string[]): void { if (typeof value === 'string') { out.push(value) @@ -333,7 +333,7 @@ function substituteString(text: string, corpus: string): string { } } -/** Deep-copy one JSON-shaped value with scripted placeholders resolved. */ +/** Deep-copy one JSON-compatible value with scripted placeholders resolved. */ function substituteValue(value: unknown, corpus: string): unknown { if (typeof value === 'string') { return value.includes(FROM_REQUEST_OPEN) ? substituteString(value, corpus) : value diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 60a7beb012..807bf66cf1 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -114,8 +114,8 @@ export class LocalTaskService extends TaskService { void hooks.done.then( (outcome) => { this.settle(task, outcome) }, (error: unknown) => { - // Contain a producer contract violation so cleanup and waiters cannot hang. - this.selfCtx.logger.warn(`tasks: task ${task.id} 'done' rejected (producer contract violation): ${String(error)}`) + // Contain a producer contract violation (`done` rejected) so cleanup and waiters cannot hang. + this.selfCtx.logger.warn(`tasks: task ${task.id} producer done promise rejected (producer contract violation): ${String(error)}`) this.settle(task, { status: 'failed', detail: String(error) }) }, ) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 29d859760f..35d7e77ff2 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -201,7 +201,7 @@ describe('LocalTaskService reads and settlement', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('async listener boom')) }) - it('contains a rejecting done as a failed outcome (producer contract violation)', async () => { + it("contains rejection from the producer's done promise as a failed outcome (producer contract violation)", async () => { const ctx = await harness() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const p = producer() diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index 0cfe48a6f2..8c2a7aa7dd 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -39,7 +39,7 @@ function validateTodos(value: unknown, fail: InvariantFailure): void { } /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ -/** Validate the package-owned event shape and ignore unrelated events. */ +/** Validate the package-owned event fields and ignore unrelated events. */ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { if (event.type === 'todo/write') validateTodos(event.data.todos, fail) } diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index 575d066e0d..2ae9722dea 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -18,8 +18,8 @@ * current entries. Package verdicts and imported manifests are cached per * package name and never expire — plugin-set changes take effect on restart. * - * Manual `ctx.typert.register()` remains the escape hatch for contributions - * that do not ride a `./typert` artifact (hand-written contract schemas, + * Manual `ctx.typert.register()` remains available for contributions + * that do not use a `./typert` artifact (hand-written wire schemas, * tests, non-loader compositions). * * @module @deepseek-ai/dsh-typert-loader @@ -68,7 +68,7 @@ function typertExportOf(pkgName: string, exportsField: unknown): string | undefi const fallback = (target as Record<string, unknown>).default if (typeof fallback === 'string') return fallback } - throw new Error(`typert-loader: ${pkgName} exports["${TYPERT_HOST_EXPORT}"] has an unsupported shape`) + throw new Error(`typert-loader: ${pkgName} exports["${TYPERT_HOST_EXPORT}"] must be a string or an object with a string default`) } /** diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index af5d451351..491538a533 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -356,7 +356,7 @@ describe('typert loader', () => { await ctx.loader.create({ name: '@fixture/export-primitive' }) await ctx.loader.await() - await expect(mountTypertLoader(ctx)).rejects.toThrow('unsupported shape') + await expect(mountTypertLoader(ctx)).rejects.toThrow('must be a string or an object with a string default') }) it('caches a negative verdict for loader entries without a package root', LOADER_TEST_TIMEOUT, async () => { diff --git a/packages/util/retention/src/index.ts b/packages/util/retention/src/index.ts index 1aca8b6aaf..73f44191a8 100644 --- a/packages/util/retention/src/index.ts +++ b/packages/util/retention/src/index.ts @@ -140,7 +140,7 @@ function assertBudget(value: number, name: string): void { * * Grouping, sorting, path mapping, per-unit preview truncation, and any * `incomplete` state stay OUTSIDE the retainer: it counts and keeps, nothing - * more. The caller pushes already-shaped units and, after {@link finish}, + * more. The caller pushes prepared logical units and, after {@link finish}, * groups/sorts the retained subset itself. */ export class ItemRetainer<T> { @@ -160,7 +160,7 @@ export class ItemRetainer<T> { * and counted as omitted. Callers keep pushing all observed units, so the final * {@link Omitted} count is exact. * - * @param item The already-shaped logical unit (path, flat match, source). + * @param item The prepared logical unit (path, flat match, source). * @returns The per-push {@link PushDecision}. */ push(item: T): PushDecision { @@ -253,7 +253,7 @@ export class TextRetainer { private suffixHeld = 0 private total = 0 - /** @param strategy One of the {@link TextRetentionStrategy} shapes; byte budgets must be non-negative integers. */ + /** @param strategy One {@link TextRetentionStrategy} variant; byte budgets must be non-negative integers. */ constructor(strategy: TextRetentionStrategy) { switch (strategy.kind) { case 'head': diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index f8f32c568a..aad4d8728c 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -58,7 +58,7 @@ export const Config: z<Config> = z.object({ fetchMaxOutputChars: z.number().default(DEFAULT_FETCH_MAX_OUTPUT_CHARS), }) -/** The shape after schemastery applies its defaults to every field. */ +/** Complete config after schemastery applies every field default. */ type ResolvedConfig = Required<Config> /** Configured count, timeout, and character caps must be positive integers. */ diff --git a/packages/web/web-fetch-local/src/index.ts b/packages/web/web-fetch-local/src/index.ts index 6e1e6b2bf6..a5636b37ee 100644 --- a/packages/web/web-fetch-local/src/index.ts +++ b/packages/web/web-fetch-local/src/index.ts @@ -55,7 +55,7 @@ export const Config: z<Config> = z.object({ userAgent: z.string().default(DEFAULT_USER_AGENT), }) -/** The shape after schemastery applies its defaults to every field. */ +/** Complete config after schemastery applies every field default. */ type ResolvedConfig = Required<Config> /** A resource limit (byte/char/length/timeout cap) must be a positive finite number. */ diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts index ff71d11c60..a41d1f8344 100644 --- a/packages/web/web-fetch-local/src/provider.ts +++ b/packages/web/web-fetch-local/src/provider.ts @@ -224,7 +224,7 @@ function resolveRedirect(location: string, base: URL): URL { /** * Translate a thrown fetch/stream error into a `WebError`, classified by the - * deadline signal rather than the error's shape (which differs by phase: the + * deadline signal rather than the thrown value (which differs by phase: the * request-phase `fetch` rejects with the abort reason, while the read-phase * reader surfaces a bare `AbortError`). `timeoutOf(signal, 'WEB_FETCH_TIMEOUT')` * recovering OUR reason means our timeout fired (`WEB_FETCH_TIMEOUT`); any other diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts index 59da2e33e0..ded152cc81 100644 --- a/packages/web/web/src/index.ts +++ b/packages/web/web/src/index.ts @@ -133,7 +133,7 @@ export class WebService extends Service { * time with the selection rules above; throws {@link WebError} when the * capability cannot run. The seam enforces `request.maxResults` on the result: * if the provider over-returns, `sources[]` is truncated and `truncated` set. - * @param request - the query plus result-shaping options. + * @param request - the query and optional result limit. * @param signal - optional cancellation signal forwarded to the provider. * @returns the provider's results, capped to `request.maxResults`. */ diff --git a/packages/web/web/src/types.ts b/packages/web/web/src/types.ts index 316b6b5c11..3ac4344ace 100644 --- a/packages/web/web/src/types.ts +++ b/packages/web/web/src/types.ts @@ -1,7 +1,7 @@ /** * Vocabulary for the web capability seam (`ctx.web`). Search and fetch deliberately share one * seam so provider selection, cancellation, errors, and product configuration have one owner, - * while retaining separate request and result shapes. + * while retaining separate request and result types. * @module @deepseek-ai/dsh-web/types */ diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml index 219318f8f3..209ac7758c 100644 --- a/packages/workflow/tool-workflow/README.i18n.yaml +++ b/packages/workflow/tool-workflow/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/workflow/tool-workflow/README.md -README.md: 4b75ce5b6a248949f135bfe1883680a645a4c774 -README.zh.md: 930da9d0975cd334a7a7cabf59958a9e11106861 +README.md: 29896bee0f78a1d1764c3908965325fcecbf7b53 +README.zh.md: 12e1ecd8932120c74384a289530954422ba145f2 diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md index 4b75ce5b6a..29896bee0f 100644 --- a/packages/workflow/tool-workflow/README.md +++ b/packages/workflow/tool-workflow/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The model-facing **`workflow` tool**: run a JavaScript orchestration script that fans out subagents, and return the script's final value. This package owns schema and lifecycle shaping over [`ctx.workflows`](../workflow/README.md); script parsing, execution, caps, and cancellation live behind the seam, while the consumer retains ownership of the parent-facing schema and result envelope. +The model-facing **`workflow` tool**: run a JavaScript orchestration script that fans out subagents, and return the script's final value. This package owns the model-facing schema and run lifecycle over [`ctx.workflows`](../workflow/README.md); script parsing, execution, caps, and cancellation live behind the seam, while the consumer retains ownership of the parent-facing schema and result envelope. ## What the model sees diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md index 930da9d097..12e1ecd893 100644 --- a/packages/workflow/tool-workflow/README.zh.md +++ b/packages/workflow/tool-workflow/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向模型的 **`workflow` 工具**:运行一段扇出 subagent 的 JavaScript 编排脚本,并返回脚本的最终值。本包负责基于 [`ctx.workflows`](../workflow/README.md) 塑造 schema 和生命周期;脚本解析、执行、上限与取消位于 seam 之后,消费方仍负责面向父级的 schema 和结果包络。 +面向模型的 **`workflow` 工具**:运行一段扇出 subagent 的 JavaScript 编排脚本,并返回脚本的最终值。本包负责基于 [`ctx.workflows`](../workflow/README.md) 定义面向模型的 schema 和运行生命周期;脚本解析、执行、上限与取消位于 seam 之后,消费方仍负责面向父级的 schema 和结果包络。 ## 模型看到的内容 diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 2531a72241..6c1e9b19bb 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -1,6 +1,6 @@ /** * The model-facing `workflow` tool: run a JavaScript orchestration script that fans out - * subagents, and return the script's final value. Pure schema + lifecycle shaping — script + * subagents, and return the script's final value. It owns the model-facing schema and run lifecycle; script * parsing, execution, caps, and cancellation live behind `ctx.workflows` * (`@deepseek-ai/dsh-workflow`), so a hardened engine swaps in without touching what the model * sees. Execution awaits `run.result` and always disposes the run; non-completed reasons become tool diff --git a/packages/workflow/workflow-workerthread/src/meta.ts b/packages/workflow/workflow-workerthread/src/meta.ts index 5412345178..5ff48f606b 100644 --- a/packages/workflow/workflow-workerthread/src/meta.ts +++ b/packages/workflow/workflow-workerthread/src/meta.ts @@ -1,6 +1,6 @@ /** - * Meta validation: check the caller-provided {@link WorkflowMeta} DATA against the shape - * contract and reject everything else loud, every violation named. Meta arrives as schema-checked + * Meta validation checks caller-provided DATA against the {@link WorkflowMeta} + * contract and rejects every violation by name. Meta arrives as schema-checked * JSON data, never evaluated script text; evaluating it on the host could run getters outside the * worker timeout that exists to isolate model-written code. * @module @deepseek-ai/dsh-workflow-workerthread/meta diff --git a/packages/workflow/workflow-workerthread/src/realm.ts b/packages/workflow/workflow-workerthread/src/realm.ts index cdcc86f8a0..546a4b56e1 100644 --- a/packages/workflow/workflow-workerthread/src/realm.ts +++ b/packages/workflow/workflow-workerthread/src/realm.ts @@ -1,7 +1,7 @@ /** * Materializes values leaving the script vm into plain JSON before they cross the worker * boundary, and renders thrown script values without rejecting the run. The walk rejects - * lossy JSON shapes but trusts model-written workflow scripts: getters and proxy traps may + * values that JSON cannot preserve but trusts model-written workflow scripts: getters and proxy traps may * run, and the vm is not a security boundary. The worker provides host-loop isolation and * forced termination, not hostile-value containment. See * .agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md for the isolation rationale. @@ -22,7 +22,7 @@ export class MaterializeError extends Error { * fall back to `message`, then `String()`. Reading those properties MAY run * script code (a getter, `toString`) — accepted under the module's trust * premise; if that code itself throws, a fixed label is returned instead. - * @param error - the thrown value, of any shape and any realm. + * @param error - any value thrown in the host or worker realm. * @returns human-readable text for the failure report; prefers the stack. */ export function renderThrown(error: unknown): string { @@ -40,7 +40,7 @@ export function renderThrown(error: unknown): string { } /** - * Whether an object's prototype chain is data-shaped: `null`, or a prototype + * Whether an object's prototype chain represents a plain data object: `null`, or a prototype * whose own prototype is `null` (the realm's `Object.prototype` — which we * cannot compare by identity across realms). A `Date`/`Map`/class instance * has a longer chain and is rejected. @@ -87,9 +87,9 @@ function materialize(value: unknown, path: string, seen: Set<object>): unknown { case 'bigint': throw new MaterializeError(path, 'bigints are not JSON data') case 'function': - throw new MaterializeError(path, 'functions cannot cross the workflow value boundary') + throw new MaterializeError(path, 'functions are not plain JSON data') case 'symbol': - throw new MaterializeError(path, 'symbols cannot cross the workflow value boundary') + throw new MaterializeError(path, 'symbols are not plain JSON data') case 'undefined': throw new MaterializeError(path, 'undefined is not JSON data') case 'object': @@ -122,7 +122,7 @@ function materializeArray(value: unknown[], path: string, seen: Set<object>): un } } if (Object.getOwnPropertySymbols(value).length > 0) { - throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow value boundary') + throw new MaterializeError(path, 'symbol-keyed properties are not plain JSON data') } return out } @@ -132,7 +132,7 @@ function materializeObject(value: object, path: string, seen: Set<object>): Reco throw new MaterializeError(path, 'only plain objects and arrays are JSON data (exotic prototype)') } if (Object.getOwnPropertySymbols(value).length > 0) { - throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow value boundary') + throw new MaterializeError(path, 'symbol-keyed properties are not plain JSON data') } const out: Record<string, unknown> = {} // Object.keys = own enumerable string keys, matching JSON.stringify's diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index 6bc749cbd9..e807d1ef5b 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -1,5 +1,5 @@ /** - * Per-run worker-side vm hooks, child RPC, concurrency/caps, cancellation, and result shaping; it + * Per-run worker-side vm hooks, child RPC, concurrency/caps, cancellation, and result serialization; it * never touches Cordis. Script values leaving the realm are materialized as plain JSON before * messaging. Values entering the trusted model-written realm are passed directly; `args` alone is * cloned so script mutation cannot alter initialization data. See `./realm.ts` for the trust model. diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index 12386659bc..bdf933a3f7 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -1,6 +1,6 @@ /** * Workflow seam vocabulary: the request/run/result types a workflow engine - * consumes and produces, plus the payload shapes of the `workflow/*` events. + * consumes and produces, plus the fields in the `workflow/*` event payloads. * Types only (plus the id-brand factory), per the package convention. * * @module @deepseek-ai/dsh-workflow/types @@ -57,8 +57,8 @@ export interface WorkflowMeta { /** * What a caller asks for when starting a workflow run. `meta` and `args` are - * plain JSON DATA by the seam contract (the tool builds both from the model's - * schema-validated call; the engine validates `meta`'s shape and rejects loud + * plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call; + * the engine validates `meta` against its schema and rejects loud * before anything runs) — an engine never evaluates script text to obtain * them. `parent` is REQUIRED — every `agent()` the script spawns is * attributed to it (cwd, lineage, depth flow through the subagent seam). @@ -66,7 +66,7 @@ export interface WorkflowMeta { export interface WorkflowStartRequest { /** The plain-JS script body (top-level await allowed; ends with `return <json-value>`). */ script: string - /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ + /** The workflow's identity fields as plain JSON data, validated by the engine. */ meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 9e0a17e270..0086d8519f 100644 --- a/python/README.i18n.yaml +++ b/python/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/README.md -README.md: 27637edb9d4d5e8714fe379a00b6aae3af1a541f -README.zh.md: b0587b25b66750780768786e02b02037dd10ec0a +README.md: 6ab9de681471c4be3bff72ddbf6ce8f118224d4d +README.zh.md: 82fca597791f19caa21a7a433e533a4d47c64ccb diff --git a/python/README.md b/python/README.md index 27637edb9d..6ab9de6814 100644 --- a/python/README.md +++ b/python/README.md @@ -13,7 +13,7 @@ Python packages for driving DeepSeek Harness as a subprocess. The client SDK com ## Behavior -The SDK starts the matching bundled runtime unless the caller selects an explicit channel. The client owns channel selection and default-configuration injection; the runtime itself always requires an explicit configuration. The [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md) own the complete resolution and configuration contracts. +The SDK starts the matching bundled runtime unless the caller selects an explicit channel. The client selects the channel and supplies default configuration; the runtime itself always requires an explicit configuration. The [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md) own the complete runtime-selection and configuration contracts. ## Contributor workflows diff --git a/python/README.zh.md b/python/README.zh.md index b0587b25b6..82fca59779 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -13,7 +13,7 @@ ## 行为 -除非调用方选择显式通道,否则 SDK 会启动匹配的内置运行时。客户端负责选择通道和注入默认配置;运行时本身始终要求显式配置。完整的解析与配置约定分别由 [SDK 参考](sdk/README.md)和[运行时载体参考](sdk-runtime/README.md)定义。 +除非调用方选择显式通道,否则 SDK 会启动匹配的内置运行时。客户端选择通道并提供默认配置;运行时本身始终要求显式配置。[SDK 参考](sdk/README.md)和[运行时载体参考](sdk-runtime/README.md)定义完整的运行时选择与配置约定。 ## 贡献者工作流 diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 68ea79ea7b..8585d16982 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — Repository scripts -Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation at the owning gate boundary instead of a shared platform layer. +Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation in the gate that needs it instead of a shared platform layer. diff --git a/scripts/archived-agent-notes.ts b/scripts/archived-agent-notes.ts index bdce541ab0..39786cca27 100644 --- a/scripts/archived-agent-notes.ts +++ b/scripts/archived-agent-notes.ts @@ -4,7 +4,7 @@ import { createHash } from 'node:crypto' import { basename } from 'node:path' import { AGENT_NOTE_CLASSES } from './agent-note-tree.ts' -/** Versioned shape of the frozen-content manifest. */ +/** Versioned fields in the frozen-content manifest. */ export interface ArchiveManifest { version: 1 files: Readonly<Record<string, string>> diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index aa3bae0b6d..d43c87f08b 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -1,5 +1,5 @@ /** - * Pins shared client-bundle preset contracts: the module-edge purity gate and + * Pins shared client-bundle preset rules: the module-edge purity gate and * the physical watch dependencies hidden behind virtual CSS Modules. */ import { fileURLToPath } from 'node:url' diff --git a/scripts/cordis-walk.ts b/scripts/cordis-walk.ts index 3f878be13c..befc6156e1 100644 --- a/scripts/cordis-walk.ts +++ b/scripts/cordis-walk.ts @@ -81,7 +81,7 @@ export function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<stri /** * Every event name a `declare module 'cordis'` Events merge declares in one * module body. Names are the literal member keys (`'agent/created'`), read - * from method and property members alike so a declaration shape the projector + * from method and property members alike so a declaration form the projector * would reject still enters the exhaustiveness scan. * @param body - The cordis module augmentation block. * @param sf - Owning source file (for computed-name text extraction). diff --git a/scripts/coverage-exempt.ts b/scripts/coverage-exempt.ts index 8f20f54424..b560567014 100644 --- a/scripts/coverage-exempt.ts +++ b/scripts/coverage-exempt.ts @@ -1,6 +1,6 @@ /** * Heavy suites the coverage aggregate runs uninstrumented in a parallel gate. - * Membership contract: a suite qualifies only when every coverage-measured + * Membership rule: a suite qualifies only when every coverage-measured * file it executes in-process (`coverage.include` spans package src trees; * typert generator src is threshold-excluded in vitest.config.ts) is already * fully covered by other suites, so removing it from the instrumented run diff --git a/scripts/doc-typecheck-paths.ts b/scripts/doc-typecheck-paths.ts index dc5a6a9d05..b054126aec 100644 --- a/scripts/doc-typecheck-paths.ts +++ b/scripts/doc-typecheck-paths.ts @@ -1,6 +1,6 @@ /** Map one workspace source alias target to its declaration-build target. */ export function builtDeclarationPath(candidate: string): string { - // Two workspace shapes exist: whole-package entries end in /src, subpath + // Two workspace path forms exist: whole-package entries end in /src, subpath // wildcards (apiproxy's browser-safe /api and /client channels) in /src/*. if (candidate.endsWith('/src')) { return `${candidate.slice(0, -'/src'.length)}/lib/types` diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index efdb03eaad..87de14ea6f 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -221,7 +221,7 @@ const { primary: all, derivatives } = partitionPairedMarkdownDerivatives( const checked = all.filter(b => b.kind === 'check') const ignored = all.filter(b => b.kind === 'ignore') // Only compile-eligible fences belong in the opt-out ratio; every other skipped -// kind has an independent verifier named in BlockKind's contract above. +// kind has an independent verifier named in the BlockKind rules above. const ratioDenominator = checked.length + ignored.length if (checked.length === 0) { diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index a79ffdd47f..12732b9aa3 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -2,7 +2,7 @@ * Generate `docs/config-catalog.md` from package entry points, config types, * JSDoc, and static Schemastery schemas. Every package must classify, referenced * types must resolve without collisions, and every enumerable schema path must - * exist on the declared config type. External and dynamic shapes stay unknown; + * exist on the declared config type. External and dynamic types stay unknown; * declared runtime-only fields need not appear in the schema. `--check` verifies * the committed artifact. */ @@ -220,7 +220,7 @@ interface World { } /** How a schema key path fared against the declared config type: definitely - * present, definitely absent, or crossing a shape the walk cannot enumerate + * present, definitely absent, or crossing a type the walk cannot enumerate * (only `missing` is a violation — `unknown` must never mis-report). */ type PathLookup = 'found' | 'missing' | 'unknown' @@ -307,7 +307,7 @@ const PASSTHROUGH_WRAPPERS = new Set(['Partial', 'Required', 'Readonly', 'NonNul /** * Walk a schema key path against a declared type. This is a PRESENCE check, - * not a shape check: it answers "does the declared config type have a member + * not a runtime value check: it answers "does the declared config type have a member * here", resolving interfaces (heritage included), type aliases, literals, * intersections, unions, arrays, indexed access, pass-through utility * wrappers, and type references across package-local and workspace imports. @@ -412,7 +412,7 @@ function unwrapExpr(expr: ts.Expression): ts.Expression { * Statically walk a schemastery schema expression to its key paths plus the * packages whose schemas an intersect composes. A key path is the top-level * key or a nested path through object/array compositions (`agents[].id`). - * Handles the shapes the repo declares — `z.object({…})` (possibly behind + * Handles the declaration forms the repo uses — `z.object({…})` (possibly behind * chained calls) and `z.intersect([X.Config, …])` — and hard-errors on * anything else, so a schema the walk cannot see fails the gate instead of * silently thinning it. Nested values that are neither `object` nor `array` @@ -521,7 +521,7 @@ function findSchemaExpr(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null): function findInject(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null, violations: string[]): string[] { const fromArray = (expr: ts.Expression, where: string): string[] => { if (!ts.isArrayLiteralExpression(expr)) { - violations.push(`${where}: inject is not a plain string-array literal; teach the generator the new shape.`) + violations.push(`${where}: inject is not a plain string-array literal; teach the generator the new declaration form.`) return [] } return expr.elements.map(el => ts.isStringLiteral(el) ? el.text : el.getText(ctx.sf)) @@ -727,7 +727,7 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] { } // Fold composed schemas' key paths in, then check each path against the type. - // Only a definite miss fails; shapes the walk cannot enumerate stay unknown. + // Only a definite miss fails; types the walk cannot enumerate stay unknown. const byName = new Map(entries.map(e => [e.pkg, e])) for (const entry of entries) { if (entry.kind !== 'config' || entry.schemaKeys === null || entry.schemaKeys === undefined) continue diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f2349d7c13..885e0cf9d2 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -111,16 +111,16 @@ export const SERVICE_PAGE: Record<string, string> = { */ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = { agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle', - configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns the launcher contract', - launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns the launcher contract', + configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract', + launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract', dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract', - headlessIo: 'not a service: launcher-provided root accessor value (HeadlessIo | undefined) for the headless bundle runner — packages/bundle/headless/README.md owns the launcher contract', - launcherEnvironment: 'not a service: launcher-provided root accessor value (EnvironmentSnapshot | undefined) — packages/util/environment/README.md owns the launcher contract', + headlessIo: 'not a service: launcher-provided root accessor value (HeadlessIo | undefined) for the headless bundle runner — packages/bundle/headless/README.md owns this launcher contract', + launcherEnvironment: 'not a service: launcher-provided root accessor value (EnvironmentSnapshot | undefined) — packages/util/environment/README.md owns this launcher contract', lsp: 'interface-typed (LspService); implementing class Lsp is not the declared type name — packages/lsp/lsp/README.md owns the surface', apiProxy: 'interface-typed (ApiProxy) with the class in api-proxy.ts, not index.ts — packages/host/apiproxy/README.md owns the surface', appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the surface', connection: 'client-side interface-typed browser service — packages/client/connection/README.md owns the surface', - chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the surface', + chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the API', command: 'client-side interface-typed browser service — packages/client/ui-command/README.md owns the surface', conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the surface', conversationEvents: 'client-side interface-typed registry — packages/client/runtime/README.md owns the surface', @@ -483,13 +483,13 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = { 'z.core.ToJSONSchemaParams': 'zod projection parameters are owned by the zod v4 API', TypeRTDisposer: 'TypeRT lifecycle contract is owned by packages/typert/type-meta/README.md', InvokeRemoteRequest: 'gateway invocation contract is owned by packages/api/gateway/README.md', - LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts', + LocaleDict: 'service-local dictionary fields are owned by packages/client/i18n/src/index.ts', ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts', Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts', WebUpgradeRoute: 'upgrade route registration contract is owned by packages/host/webserver/src/index.ts', InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', - KnobState: 'projection unit state shape is owned by packages/interaction/permission/README.md', + KnobState: 'projection unit state fields are owned by packages/interaction/permission/README.md', PermissionSelect: 'permissions projection payload is owned by packages/interaction/permission/src/types.ts', PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md', Sandbox: 'external E2B SDK handle is owned by packages/e2b/e2b/README.md', @@ -752,7 +752,7 @@ export function maybeRecordPair(pageRel: string, before: Map<string, Buffer>, sc // after review, never silently by regeneration. return false } - // The record must be exactly the well-formed two-entry shape for THIS pair; + // The record must contain exactly the two valid entries for THIS pair; // a malformed or renamed-key sidecar is the pairing gate's problem to // report, never something regeneration silently repairs into validity. const recorded = parsePairMeta(meta) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index e9f2f806ae..aa875ca369 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -521,7 +521,7 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'seam', implementations: ['workflow-workerthread'], consumers: ['tool-workflow', 'tool-ralph'], - note: 'One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.', + note: 'One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.', }, ] @@ -815,7 +815,7 @@ export class EventRelationCollector { * Return every indexed call resolving to one local helper declaration. * Fast path: when every same-file reference to the non-exported helper is * provably a direct callee, module scoping confines all of its calls to that - * file, so only that file is indexed. Any other reference shape may alias + * file, so only that file is indexed. Any other reference form may alias * the function value outward, so the original full package-source index * decides instead. */ @@ -1155,7 +1155,7 @@ function renderEventRelations(pkgs: Pkg[], events: readonly EventEntry[]): strin lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`) } // Every declared event needs a dispatcher: zero means dead vocabulary or an - // unrecognized semantic dispatch shape. Listener-free extension points remain + // unrecognized semantic dispatch form. Listener-free extension points remain // valid. Client-declared events are exempt: the relation scan seeds the HOST // aggregate program only (host+client cannot share one program — the cordis // Context merges collide), so client dispatch sites are structurally @@ -1168,8 +1168,8 @@ function renderEventRelations(pkgs: Pkg[], events: readonly EventEntry[]): strin if (undispatched.length > 0) { throw new Error( `event-producer-consumer matrix: no dispatcher found for declared event${undispatched.length > 1 ? 's' : ''} ` - + `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch shape the semantic scan misses ` - + '(teach scripts/gen-doc-graphs.ts the shape)', + + `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch form the semantic scan misses ` + + '(teach scripts/gen-doc-graphs.ts that form)', ) } const declared = new Set(events.map(event => event.name)) @@ -1262,9 +1262,9 @@ function renderLifecycle(): string { '', '`dsh-compact-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.', '', - 'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later boundary claims their next-step batch.', + 'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.', '', - 'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.', + 'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request construction, steering, continuation, and errors.', '', ...maintenanceFooter(maintenance), ].join('\n') @@ -1274,7 +1274,7 @@ function renderToolPipeline(): string { const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs' return [ ...generatedHeader('Tool Execution Pipeline'), - 'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards, definition-owned `finalizeContent`, and `tools/result` are the owner-enforced boundaries around them.', + 'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `finalizeContent` and `tools/result` run afterward.', '', '```mermaid', 'flowchart TD', @@ -1380,7 +1380,7 @@ function renderIndex(docs: GraphDoc[]): string { const maintenance = 'mixed: each linked page declares generated, hybrid, or curated mode' return [ ...generatedHeader('Documentation Graph Index'), - 'These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the [subsystem pages](subsystems/core.md) (types + the generated `cordis-surface` regions) and [tool-catalog.md](tool-catalog.md).', + 'These diagrams show relationships that the generated catalogs do not. Use them to find package relationships, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type definitions still live in the [subsystem pages](subsystems/core.md) (types + the generated `cordis-surface` regions) and [tool-catalog.md](tool-catalog.md).', '', 'The process decision behind this index is recorded in [the documentation graph Agent Note](../.agents/notes/archived/process/2026-07-03-documentation-graph-atlas.md).', '', diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index c9b1f41dbf..aa3198057b 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -139,7 +139,7 @@ describe('parseVendoredRows', () => { expect(rows.every(row => /^https:\/\/\S+$/.test(row.upstream))).toBe(true) }) - it('yields nothing when the table shape changes, so the generator fails loud', () => { + it('yields nothing when the table columns change, so the generator fails loud', () => { expect(parseVendoredRows('| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([]) }) @@ -215,7 +215,7 @@ describe('parsePyprojectRequirements', () => { ].join('\n'))).toEqual(['pydantic', 'tomli', 'pytest']) }) - it('accepts dependency-group includes and rejects unsupported requirement shapes', () => { + it('accepts dependency-group includes and rejects unsupported requirement forms', () => { expect(parsePyprojectRequirements('[dependency-groups]\nbase = ["pytest"]\nall = [{ include-group = "base" }]\n')) .toEqual(['pytest']) expect(() => parsePyprojectRequirements('[project]\ndependencies = "pytest"\n')).toThrow(/must be an array/) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 9802adacf2..c2ab21688a 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -475,7 +475,7 @@ function collectPythonRequirementArray( } } -/** Read an optional TOML table and reject a present value of another shape. */ +/** Read an optional TOML table and reject a present non-table value. */ function optionalTomlTable(value: TomlValueWithoutBigInt | undefined, location: string): TomlTableWithoutBigInt | undefined { if (value === undefined || isTomlTable(value)) return value throw new Error(`gen-third-party-notices: ${location} must be a table.`) @@ -487,7 +487,7 @@ function optionalTomlTable(value: TomlValueWithoutBigInt | undefined, location: * `[build-system]`, `dependencies` under `[project]`, and every key under * `[project.optional-dependencies]` and `[dependency-groups]`. A TOML parser * owns comments, quoted keys, escapes, and array boundaries; unsupported - * requirement shapes fail instead of disappearing from the notices. + * requirement forms fail instead of disappearing from the notices. * @param text - the complete `pyproject.toml` contents. * @returns the local project name and declared requirement names. */ diff --git a/scripts/gen-translation-brief.ts b/scripts/gen-translation-brief.ts index a979d2652a..0a0d03cc61 100644 --- a/scripts/gen-translation-brief.ts +++ b/scripts/gen-translation-brief.ts @@ -7,7 +7,7 @@ * the narrowest safe granularity — code-fence-only splice, changed * Markdown units, heading sections, whole document — and `--apply` writes * the computed counterpart for pairs whose change is code-fence-only. - * The briefing contract lives in `scripts/translation-brief.ts`; the + * The briefing rules live in `scripts/translation-brief.ts`; the * consuming workflow is `.agents/skills/dsh-translate-docs/SKILL.md`. */ diff --git a/scripts/lint-rule-fingerprint.spec.ts b/scripts/lint-rule-fingerprint.spec.ts index b1f57eba7b..0db617ba59 100644 --- a/scripts/lint-rule-fingerprint.spec.ts +++ b/scripts/lint-rule-fingerprint.spec.ts @@ -15,7 +15,7 @@ interface Profile { // A one-time audit against eslint.config.mjs blob 696b08282885296830189fdafe7051a356806fc2 // mapped @typescript-eslint/* to typescript/* and four extension rules to their // Oxlint core equivalents. These fingerprints pin the resulting repository -// contract; they do not re-evaluate that deleted baseline or track its preset. +// snapshot; they do not re-evaluate that deleted baseline or track its preset. const profiles = { source: { count: 88, @@ -84,7 +84,7 @@ describe('Oxlint repository rule fingerprint', () => { } const overrides: readonly unknown[] = parsed.overrides - it('pins the complete override shape', () => { + it('pins every override field', () => { expect(overrides).toHaveLength(8) }) diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 54318ca94f..eeae171a0b 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -19,7 +19,7 @@ interface PackageManifest { devDependencies?: Record<string, string> } -/** One package and the files participating in its invariant publication contract. */ +/** One package and the files participating in its invariant publication rules. */ export interface PackageInvariantOwner { readonly dir: string readonly manifestPath: string @@ -53,7 +53,7 @@ export function packageInvariantOwners(root: string): PackageInvariantOwner[] { }) } -/** Return all violations of the package-invariant companion contract. */ +/** Return all violations of the package-invariant companion rules. */ export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] { const violations: PackageInvariantViolation[] = [] for (const owner of packageInvariantOwners(root)) { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index cbcf8504d5..e8055db866 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -341,7 +341,7 @@ function nodeCompatSmokeGates(options: { cliSmoke?: boolean } = {}): Gate[] { return gates } -/** Active Node major used to scope version-specific compatibility contracts. */ +/** Active Node major used to select version-specific compatibility checks. */ function runningNodeMajor(): number { const major = Number.parseInt(process.versions.node.split('.')[0] ?? '', 10) if (!Number.isSafeInteger(major)) { @@ -473,7 +473,7 @@ function lintGate(options: { needs?: string[] } = {}): Gate { // The heavy suites run uninstrumented beside the thresholded gate: their // compiler- and subprocess-bound fixtures pay a multiple of their runtime // under v8 instrumentation while contributing nothing the thresholds need -// (membership contract in scripts/coverage-exempt.ts). +// (membership rules in scripts/coverage-exempt.ts). // // DSH_COVERAGE_MAX_WORKERS is the lane's worker budget, so the two parallel // gates split it instead of each claiming it whole (the failover pool's diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 587d59a8bd..bea681d55a 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,31 +4,31 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing `<final>`.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering idiom over literal renderings, and localize metaphors instead of transplanting them.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `<review>` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in `<review>`. A tentative rendering may appear in `<review>` but must not be silently adopted in `<translation>` or `<final>`, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/bash` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required shape; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(First pass: the complete translation, written as natural target-language technical prose)\n</translation>\n\n<review>\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n</review>\n\n<final>\n(Complete final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, verify it in two directions. First re-read it in the target language only, without looking at the source; awkward phrasing is easier to notice without source-language anchoring. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing `<review>`; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Is the heading hierarchy and order, list shape and count, ordered-list start, table shape, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or transplanted metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in `<review>`, then output the corrected complete document in `<final>`. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `<review>` and copy `<translation>` unchanged into `<final>`. If `<review>` contains only pending terminology notices, copy `<translation>` unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing `<final>`.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `<review>` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in `<review>`. A tentative rendering may appear in `<review>` but must not be silently adopted in `<translation>` or `<final>`, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/bash` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(First pass: the complete translation, written as natural target-language technical prose)\n</translation>\n\n<review>\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n</review>\n\n<final>\n(Complete final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing `<review>`; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in `<review>`, then output the corrected complete document in `<final>`. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `<review>` and copy `<translation>` unchanged into `<final>`. If `<review>` contains only pending terminology notices, copy `<translation>` unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that must be fixed before a new release. A release must not include an unresolved FIXME unless reviewers explicitly approve merging the change without fixing it.`\n- Bad: `FIXME——新版本之前必须修复的问题。除非评审者明确批准带着问题合入,否则版本里不能有未解决的 FIXME。`\n- Good: `FIXME:新版本发布前必须修复的问题。除非评审者明确批准在不修复的情况下合并该更改,否则发布版本不得包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/<name>`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow <a href=\"https://x.com/Deepseekharness\">DeepSeek Harness on Twitter</a> for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/<name>`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow <a href=\"https://x.com/Deepseekharness\">DeepSeek Harness on Twitter</a> for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/<name>` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)约定](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 <a href=\"https://wj.qq.com/s2/27234598/03eb/\">DeepSeek Harness 微信社区申请页面</a> 申请加入。\n\n<p>\n <img src=\"assets/community-wecom-survey.png\" alt=\"DeepSeek Harness 微信社区二维码\" width=\"240\">\n</p>\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/<name>` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 <a href=\"https://wj.qq.com/s2/27234598/03eb/\">DeepSeek Harness 微信社区申请页面</a> 申请加入。\n\n<p>\n <img src=\"assets/community-wecom-survey.png\" alt=\"DeepSeek Harness 微信社区二维码\" width=\"240\">\n</p>\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already owns an explicit dependency on the TypeRT contract pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact boundary.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the TypeRT contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本以调用它的公共命令或调度器门禁已经显式依赖 TypeRT 约定 pass 或完整构建为前提。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。确切边界见[双语文档约定](i18n/README.md#the-pairing-contract)。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 TypeRT 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, enforcement gate, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief <pair>` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any uncertain shape remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing <pair...>` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write <pair>`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" + "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief <pair>` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing <pair...>` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write <pair>`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、强制门禁、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief <pair>` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何无法确定的情形都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing <pair...>` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write <pair>`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" + "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief <pair>` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing <pair...>` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write <pair>`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" }, { "role": "user", diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 2c00bc7439..db3cae7073 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -137,7 +137,7 @@ describe('global test invariant host', () => { .toEqual(Object.keys(testInvariantCompanions).sort()) }) - it('loads and executes every source companion through the real Loader shape', async () => { + it('loads and executes every source companion through the real Loader setup', async () => { const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName])) const registrations = new Map<string, string>() const loader = Object.create(Loader.prototype) as Loader diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index d6f05eee90..9235f34e46 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -24,7 +24,7 @@ declare global { } } -/** Loader-safe shape shared by every package invariant companion. */ +/** Loader-safe exports shared by every package invariant companion. */ export interface TestInvariantCompanion { readonly name: string readonly inject: readonly string[] diff --git a/scripts/translation-pairing-git.ts b/scripts/translation-pairing-git.ts index cda27426c6..5f1ddd4fc0 100644 --- a/scripts/translation-pairing-git.ts +++ b/scripts/translation-pairing-git.ts @@ -54,7 +54,7 @@ export interface GitIndexBlob { * @param root - Repository root. * @param path - Repository-relative path. * @returns The stage-zero blob, or `undefined` when the path is absent. - * @throws Error when the path is unmerged or has an invalid index shape. + * @throws Error when the path is unmerged or its index entries are not a valid merge state. */ export function readGitIndexBlob(root: string, path: string): GitIndexBlob | undefined { const output = runGit( diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index e80605c14b..6c6e3b8226 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -80,7 +80,7 @@ const PAIR_META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/ /** * Parse a `foo.i18n.yaml` consistency record into basename → recorded blob * hash, or undefined when any non-comment line deviates from the exact - * `<basename>.md: <40-hex>` shape or repeats a key. Consumers must + * `<basename>.md: <40-hex>` format or repeats a key. Consumers must * additionally require exactly the two expected basenames — a renamed key is * a malformed record, never a silently-missing entry. * @param content - Sidecar file text. @@ -118,7 +118,7 @@ export function renderPairMeta(source: string, sourceHash: string, zh: string, z ].join('\n') } -/** Validated shape of `scripts/translation-pairing.manifest.json`. */ +/** Validated fields of `scripts/translation-pairing.manifest.json`. */ export interface TranslationPairingManifest { /** Source documents exempt from pairing because they are generated, instructional, or bilingual by construction. */ excluded: string[] diff --git a/scripts/translation-prompt.spec.ts b/scripts/translation-prompt.spec.ts index e3b14f169e..65160896c7 100644 --- a/scripts/translation-prompt.spec.ts +++ b/scripts/translation-prompt.spec.ts @@ -21,7 +21,7 @@ const retainedExamples = [ ['### Stiff passive voice → Active and natural', 'a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.', '门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。'], ['### Invented word → Natural expression', 'A sidecar record of both blob hashes makes consistency checkable', '伴随记录保存两侧 blob hash,使一致性可检查'], ['### Em-dash → Colon/period', 'FIXME — an issue that should block a new release.', 'FIXME:应当阻塞新版本发布的问题。'], - ['### Overly literal → Meaningful rendering', 'awkward phrasing is easier to hear without the source anchoring you', '不对照原文时,更容易察觉别扭的表达'], + ['### Overly literal → Meaningful rendering', 'awkward phrasing is easier to notice when you read the translation without comparing it with the source', '不对照原文阅读译文时,更容易察觉别扭的表达'], ['### Terminology — do not translate what should be kept in English', 'typed service seams, and explicit extension points', '类型化的服务 seam 与显式扩展点'], ['### Slang/jargon → Professional phrasing', 'The committed agent workflow lives in .agents/skills/dsh-translate-docs', '仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs'], ['### "For humans" — translate the intent, not the word', 'For humans, start with the development guide', '面向开发者:请先阅读开发指南'], @@ -44,7 +44,7 @@ describe('translation prompt rendering', () => { expect(zh).toContain('from Chinese to English') }) - it('retains every v4 embedded example', () => { + it('contains every embedded example', () => { for (const example of retainedExamples) { for (const fragment of example) expect(document).toContain(fragment) } diff --git a/scripts/translation-prompt.ts b/scripts/translation-prompt.ts index aaf114efe8..91bd8e3210 100644 --- a/scripts/translation-prompt.ts +++ b/scripts/translation-prompt.ts @@ -169,7 +169,7 @@ function unescapeResponseBody(value: string): string { }).join('\n') } -/** Serialize a response in the exact escaped three-section shape the prompt requests. */ +/** Serialize a response in the exact escaped three-section format the prompt requests. */ export function renderTranslationResponse(response: TranslationResponse): string { return RESPONSE_SECTIONS.map(section => `<${section}>\n${escapeResponseBody(response[section])}\n</${section}>`).join('\n\n') } @@ -178,7 +178,7 @@ export function renderTranslationResponse(response: TranslationResponse): string * Parse the three-section response. Sections must each appear exactly once * and in order; escaped delimiter lines in Markdown bodies are restored. * A fenced ```xml wrapper around the whole response is tolerated, matching - * the shape some models echo back from the prompt's own example. + * the wrapper some models copy from the prompt's own example. */ export function parseTranslationResponse(text: string): TranslationResponse { let body = text.trim() diff --git a/scripts/verify-agent-note-classification.ts b/scripts/verify-agent-note-classification.ts index 776e155f4f..588a32c3fc 100644 --- a/scripts/verify-agent-note-classification.ts +++ b/scripts/verify-agent-note-classification.ts @@ -1,6 +1,6 @@ /** * Enforce Agent Note lifecycle/class paths and dated filenames. Structural rules - * are shared with `agent-note-tree.ts`; the closed classification contract lives + * are shared with `agent-note-tree.ts`; the closed classification rules live * in `.agents/notes/README.md`. */ diff --git a/scripts/verify-agent-note-format.ts b/scripts/verify-agent-note-format.ts index 39018588e6..0ca93b298e 100644 --- a/scripts/verify-agent-note-format.ts +++ b/scripts/verify-agent-note-format.ts @@ -9,7 +9,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { agentNoteRoot, walkAgentNoteTree } from './agent-note-tree.ts' -/** The date the format contract landed; the grandfather comment is valid only before it. */ +/** The date these format rules took effect; the grandfather comment is valid only before it. */ const FORMAT_ADOPTED = '2026-07-05' /** The exact comment a pre-format Agent Note carries in place of `## Alternatives considered`. */ diff --git a/scripts/verify-archived-agent-notes.ts b/scripts/verify-archived-agent-notes.ts index ca27dd8852..d2f12f826e 100644 --- a/scripts/verify-archived-agent-notes.ts +++ b/scripts/verify-archived-agent-notes.ts @@ -99,7 +99,7 @@ if (!writeMode) { } if (errors.length > 0) { - console.error('verify-archived-agent-notes: archive contract violated:') + console.error('verify-archived-agent-notes: archive rules violated:') for (const error of errors) console.error(` ${error}`) process.exit(1) } diff --git a/scripts/verify-config-source-ownership.ts b/scripts/verify-config-source-ownership.ts index b0f4b89cdf..0684124215 100644 --- a/scripts/verify-config-source-ownership.ts +++ b/scripts/verify-config-source-ownership.ts @@ -19,7 +19,7 @@ const SHIPPED_CONFIG_GLOBS = [ 'python/*/src/**/cordis.yml', ] -/** Ordinary single-line forms this narrow source-shape check rejects; not full YAML analysis. */ +/** Ordinary single-line configuration forms this source check rejects; not full YAML analysis. */ const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js\b/ /** Return every forbidden inline environment form in shipped configuration. */ diff --git a/scripts/verify-export-jsdoc.ts b/scripts/verify-export-jsdoc.ts index 988c3a2e38..bcc2c2cfbd 100644 --- a/scripts/verify-export-jsdoc.ts +++ b/scripts/verify-export-jsdoc.ts @@ -75,9 +75,9 @@ function unwrapExpression(e: ts.Expression): ts.Expression { /** * Classify inline callable annotations. Mixed callable literals fail closed; - * other annotations are ordinary value shapes. + * other annotations are ordinary value types. * @param type - the declarator's type annotation. - * @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable shape. + * @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable type. */ function callableAnnotation(type: ts.TypeNode): ts.SignatureDeclarationBase | 'refuse' | null { if (ts.isFunctionTypeNode(type)) return type @@ -446,7 +446,7 @@ function checkScope( if (ts.isExportAssignment(stmt)) { if (stmt.isExportEquals) { // `export =` has no ESM consumer surface in this repo and the walk - // cannot classify its operand's shape; refuse rather than fail open. + // cannot classify its operand's type; refuse rather than fail open. w.violations.push(`export-equals assignment (${pointer(w.rel, w.sf, stmt)}) is not a gate-supported export form; use ESM named exports.`) continue } diff --git a/scripts/verify-package-invariants.ts b/scripts/verify-package-invariants.ts index 32e34539fa..dc686b1018 100644 --- a/scripts/verify-package-invariants.ts +++ b/scripts/verify-package-invariants.ts @@ -1,4 +1,4 @@ -/** Verify package-owned invariant source and publication contracts. */ +/** Verify package-owned invariant source and publication rules. */ import { resolve } from 'node:path' import { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 1a1de87d09..0a202e6142 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -217,12 +217,12 @@ function validateNestedVerbatim(raw: readonly string[], fragments: Set<string>): return { blocks } } -/** GitHub-style fragment for the simple ASCII nested titles allowed by this contract. */ +/** GitHub-style fragment for the simple ASCII nested titles allowed by these rules. */ function headingFragment(title: string): string { return title.toLowerCase().replaceAll('`', '').replaceAll(/[^a-z0-9 _-]/g, '').trim().replaceAll(/\s+/g, '-') } -/** A direct stable system-prompt contribution, as named by the README contract. */ +/** A direct stable system-prompt contribution, as named by the README rules. */ function isDirectSystemPromptSurface(title: string): boolean { return /\bsystem prompt\b/i.test(title) } diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 95a2fe9747..e91661e5e4 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -291,6 +291,6 @@ if (errors.length === 0) { process.exit(0) } -console.error('verify-translation-pairing: bilingual pairing contract violated (see docs/i18n/README.md):') +console.error('verify-translation-pairing: bilingual pairing rules violated (see docs/i18n/README.md):') for (const message of errors) console.error(` ${message}`) process.exit(1) diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md index 30c5cc7bdc..119725274c 100644 --- a/skills/dsh-upgrade/SKILL.md +++ b/skills/dsh-upgrade/SKILL.md @@ -33,7 +33,7 @@ The worktree name is always `staging-<timestamp>` under `<source>`, never derive 3. Allocate the timestamp and new staging worktree path. Acquire the installed worktree's existing `.agents/merge.lock`, repeat every precondition, and keep it through preparation, validation, and the `current` cutover. If staging moves while waiting, unlock and restart with a new timestamp; remove only attempt artifacts that this run created and verified as disposable. 4. In the main clone, create `refs/dsh-upgrade/recovery-<timestamp>` at the recorded old staging tip and `dsh-upgrade/prepare-<timestamp>` from that tip. Fetch exact authoritative upstream `master` into `refs/dsh-upgrade/upstream-<timestamp>` and record its object ID. Add a fresh worktree `<source>/staging-<timestamp>` checked out on the preparation branch. Confirm the main clone's `.git/info/exclude` excludes `.agents/merge.lock`, which the new worktree inherits. 5. Inspect the Git log and commit ranges between the staging base, old staging tip, and fetched upstream tip. Identify incoming upstream changes, personal commits to preserve, likely duplicates, and conflict-prone areas before rebasing. -6. In the new worktree, rebase the preparation branch onto the fetched upstream commit. Preserve intentional customizations and drop behavior already upstream. If upstream contains the customization and its remaining local diff only documents that customization, prefer upstream and drop the documentary diff rather than retaining a stale local account. Preserve documentation only when it adds a current, independently useful contract absent upstream. Abort without changing the installed launcher when resolution is uncertain. +6. In the new worktree, rebase the preparation branch onto the fetched upstream commit. Preserve intentional customizations and drop behavior already upstream. If upstream contains the customization and its remaining local diff only documents that customization, prefer upstream and drop the documentary diff rather than retaining a stale local account. Preserve documentation only when it adds current, independently useful behavior or rules absent upstream. Abort without changing the installed launcher when resolution is uncertain. 7. Install dependencies in the new worktree, review the resulting diff, and run the repository-required checks. Fix failures and rerun affected checks. Test the new worktree's `bin/dsh` directly. 8. Point `dsh-staging/<timestamp>` at the validated prepared tip and check it out in the new worktree. Ensure its `.agents/merge.lock` exists (Git-excluded through the shared main-clone exclude). Verify its branch, exact commit, clean status, remotes, dependencies, and absence of in-progress Git operations, then smoke its `bin/dsh` from a clean temporary workspace. The preparation branch remains temporary; the timestamped staging branch owns the installed commit. 9. Recheck the old worktree, existing lock, launcher, `current`, main clone, new worktree, refs, and exact tips. Record `current`'s pre-cutover target, then repoint `current` at the new staging worktree in one atomic swap with `ln -sfn` (the `-n` stops `ln` from dereferencing the existing directory symlink and writing the link inside the old worktree; `mv` behaves the same way and is unusable). Leave the PATH launcher alone once it already resolves through `current`; if a legacy install still links PATH straight at a worktree, create `current` and repoint PATH to `current/bin/dsh` as a one-time migration here. The `current` target must be a clean staging worktree on a staging branch and must never be the main clone or a preparation, feature, review, publication, or detached checkout. Smoke the installed `dsh` command from a clean temporary workspace. From 51b2fc35f336fec5ccbfc14342b5fe6d07d45ab9 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Mon, 10 Aug 2026 16:15:30 +0800 Subject: [PATCH 092/229] docs: refresh translation prompt snapshot --- .../translation-prompt-v4/request-response.expected.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index bea681d55a..7f2c71353e 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,7 +4,7 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing `<final>`.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `<review>` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in `<review>`. A tentative rendering may appear in `<review>` but must not be silently adopted in `<translation>` or `<final>`, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/bash` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(First pass: the complete translation, written as natural target-language technical prose)\n</translation>\n\n<review>\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n</review>\n\n<final>\n(Complete final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing `<review>`; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in `<review>`, then output the corrected complete document in `<final>`. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `<review>` and copy `<translation>` unchanged into `<final>`. If `<review>` contains only pending terminology notices, copy `<translation>` unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that must be fixed before a new release. A release must not include an unresolved FIXME unless reviewers explicitly approve merging the change without fixing it.`\n- Bad: `FIXME——新版本之前必须修复的问题。除非评审者明确批准带着问题合入,否则版本里不能有未解决的 FIXME。`\n- Good: `FIXME:新版本发布前必须修复的问题。除非评审者明确批准在不修复的情况下合并该更改,否则发布版本不得包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing `<final>`.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `<review>` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in `<review>`. A tentative rendering may appear in `<review>` but must not be silently adopted in `<translation>` or `<final>`, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/bash` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(First pass: the complete translation, written as natural target-language technical prose)\n</translation>\n\n<review>\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n</review>\n\n<final>\n(Complete final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing `<review>`; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in `<review>`, then output the corrected complete document in `<final>`. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `<review>` and copy `<translation>` unchanged into `<final>`. If `<review>` contains only pending terminology notices, copy `<translation>` unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", From cf5fbd02b8c6480cd00d2d9631d648eeefe758a7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 16:34:29 +0800 Subject: [PATCH 093/229] fix(tasks-local): return the layer disposer directly and cover scoped teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ScopedLayers.effect` already returns an exact `() => void`, so the inherited `() => void dispose()` wrapper voided a void — two lint rules, four errors. The scoped layer's own teardown had no test, which is the registry-contribution disposal contract the testing policy requires and the only path that calls `TaskLayer.isEmpty()`: `ScopedLayers` prunes a scope's layer when its last contribution disposes. The new case mounts one plugin contributing both a surface and a listener into one scope, then unloads it and observes that the agents which joined that scope are refused again. Refs #2141 --- packages/tasks/tasks-local/src/index.ts | 6 ++--- .../tasks/tasks-local/tests/tasks.spec.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 146d3e4ca7..cb7de27ecd 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -238,23 +238,21 @@ export class LocalTaskService extends TaskService { } onTaskDone(listener: TaskDoneListener): () => void { - const dispose = this.layers.effect( + return this.layers.effect( this.ctx, layer => layer.listeners.append(listener), { label: 'tasks.onTaskDone()' }, ) - return () => void dispose() } attachSurface(name: string): () => void { // One token per call keeps duplicate labels independently disposable. const token = Symbol(name) - const dispose = this.layers.effect( + return this.layers.effect( this.ctx, layer => layer.surfaces.append(token), { label: 'tasks.attachSurface()' }, ) - return () => void dispose() } /** diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index bdaa31d975..6a7eb44d09 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -805,6 +805,30 @@ describe('LocalTaskService disposal', () => { expect(ownerEffects()).toHaveLength(0) }) + it('drops a scoped layer when its registrations dispose', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + const standing = createScope(ctx, {}) + // One mount contributes both kinds into the same layer, as `tool-tasks` + // does; unloading it must leave nothing serving the agents that joined it. + const mount = await standing.ctx.plugin({ + inject: ['tasks'], + apply(pluginCtx: Context) { + pluginCtx.tasks.attachSurface('tool-tasks') + pluginCtx.tasks.onTaskDone(() => {}) + }, + }) + const owner = stubAgent(ctx, 'joined', scopeOf(standing.ctx)) + ctx.agents.register(owner) + expect(() => ctx.tasks.start(producer({ owner }).spec)).not.toThrow() + + await mount.dispose() + + expect(() => ctx.tasks.start(producer({ owner }).spec)) + .toThrow('no control surface serves this agent') + }) + it('detaching the last surface re-arms the register fence', async () => { const ctx = new Context() await ctx.plugin(LocalTaskService) From 76feeece55d72d4a903ff9ee8b42c06a08a42a29 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 16:37:01 +0800 Subject: [PATCH 094/229] test(web): pin the assembled snapshot lane's locale through the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assembled Web snapshot lane selected its locale with a `dsh.locale` localStorage key. That key stopped selecting anything once the locale preference moved to the Host settings document, so the image-display scenario's Chinese expectations met the English default and failed. Pin the navigator languages the boot env already documents, and state the image-display expectations in the lane's English copy — the fixture session title stays Chinese because it is fixture data, not product copy. --- apps/web/tests/assembled-boot.ts | 12 +++++++++++- apps/web/tests/image-display.snapshot.ts | 18 ++++++++---------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 631196c652..ac0ec80fcb 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -70,7 +70,12 @@ let unmount: (() => void) | undefined export function installAssembledBootEnv(): void { beforeEach(() => { localStorage.clear() - localStorage.setItem('dsh.locale', 'en') + // The locale service derives its provisional locale from the browser and + // takes an explicit choice only from Host settings, which this lane's + // fixture transport does not serve; pinning the navigator is what selects + // English here. + Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true }) + Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true }) document.title = 'DeepSeek Harness' vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => @@ -88,6 +93,11 @@ export function installAssembledBootEnv(): void { document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) document.title = '' history.replaceState(null, '', '/') + // Deleting the own properties uncovers jsdom's own accessors again + // (Navigator declares both readonly, hence the erased receiver). + const ownNavigator = navigator as unknown as Record<string, unknown> + delete ownNavigator.languages + delete ownNavigator.language vi.unstubAllGlobals() }) } diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 6546a79f1b..df286ec1c3 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -14,7 +14,7 @@ installAssembledBootEnv() /** Open the fixture history session (the alpha log carrying the turn-72 image pair) and wait for its gallery. */ async function openFixtureSession(): Promise<void> { - const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 }) + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) const group = (await within(tree).findAllByText('fixture')) .map(el => el.closest<HTMLElement>('[role="treeitem"]')) .find(el => el?.getAttribute('aria-expanded') !== null) @@ -33,7 +33,6 @@ async function openFixtureSession(): Promise<void> { } it('renders the history image pair through the authorized attachment route and opens the lightbox', async () => { - localStorage.setItem('dsh.locale', 'zh') mountAssembledApp() await openFixtureSession() @@ -73,24 +72,23 @@ it('renders the history image pair through the authorized attachment route and o fireEvent.doubleClick(frame) const lightbox = await screen.findByRole('dialog') expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob') - fireEvent.click(within(lightbox).getByRole('button', { name: /关闭/ })) + fireEvent.click(within(lightbox).getByRole('button', { name: /Close/ })) await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() }) }) it('accepts pasted images into the composer rail in order and removes them', async () => { - localStorage.setItem('dsh.locale', 'zh') mountAssembledApp() - const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 }) - const start = tree.querySelector<HTMLButtonElement>('button[aria-label="在“fixture”中新建会话"]') + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]') if (start === null) throw new Error('fixture Workspace new-session action missing') fireEvent.click(start) // Image-only send arming is pinned at package level (input-bar.spec.tsx); // this assembled lane pins the intake chain over the built graph. - const textarea = await screen.findByPlaceholderText('描述你想要构建的内容', {}, { timeout: 10_000 }) + const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' }) fireEvent.paste(textarea, { clipboardData: { @@ -102,7 +100,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy // The rail is an accessible group holding the draft thumbnail (queried via // DOM: jsdom's a11y-visibility computation hides the composer subtree). const rail = await waitFor(() => { - const el = document.querySelector('[role="group"][aria-label="待发送图片"]') + const el = document.querySelector('[role="group"][aria-label="Pending images"]') if (el === null) throw new Error('attachment rail missing') return el }, { timeout: 5_000 }) @@ -129,10 +127,10 @@ it('accepts pasted images into the composer rail in order and removes them', asy .toEqual(['pasted.png', 'second.png']) }) - const remove = [...rail.querySelectorAll('button[aria-label^="移除图片"]')] + const remove = [...rail.querySelectorAll('button[aria-label^="Remove image"]')] if (remove.length !== 2) throw new Error('remove buttons missing') for (const button of remove) fireEvent.click(button) await waitFor(() => { - expect(document.querySelector('[role="group"][aria-label="待发送图片"]')).toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() }) }) From 14dc8cd349ff03c551fe91bbfab7ad6ae1ac01ca Mon Sep 17 00:00:00 2001 From: pku-xht <xht@deepseek.com> Date: Mon, 10 Aug 2026 16:57:56 +0800 Subject: [PATCH 095/229] docs(agent-presets): record shared product provider placement --- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 2 +- ...2026-08-03-per-session-agent-presets.zh.md | 2 +- ...ubagent-providers-in-shared-host.i18n.yaml | 6 +++ ...oduct-subagent-providers-in-shared-host.md | 41 +++++++++++++++++++ ...ct-subagent-providers-in-shared-host.zh.md | 41 +++++++++++++++++++ ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 6 +-- ...ude-code-and-codex-subagent-backends.zh.md | 8 ++-- examples/acp-agent/tests/acp.snapshot.ts | 4 ++ packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 1 + packages/bundle/base/README.zh.md | 1 + .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 5 ++- .../subagent-claude-code/README.zh.md | 5 ++- .../subagent-claude-code/src/process.ts | 2 + .../tests/real-product.spec.ts | 2 +- 18 files changed, 120 insertions(+), 22 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md create mode 100644 .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 1f9868917b..95b171d364 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: ca3e6967504ac62b1d79ec28ef9dbd4bf8383bac -2026-08-03-per-session-agent-presets.zh.md: 94651d465939d760554ed8637376ae77cfae6812 +2026-08-03-per-session-agent-presets.md: 98ff20d1c0e6e5c0369a064790880438a67b2992 +2026-08-03-per-session-agent-presets.zh.md: 2845db46cbdaa64dcfaeda90cfed69325ef251e8 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index ca3e696750..98ff20d1c0 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -55,7 +55,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Those three are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` and `select` deliberately stay ordinary. The roster carries ids and trust only, and a LAN client's picker needs it; and choosing a preset looked like escalation — one of them mounts the toolset that edits the live runtime — but `session.create` already takes an `agentPreset`, so pinning only the switch would have left the same capability one method over. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. -**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the fixed Codex and Claude Code product providers, are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. +**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the [fixed Codex and Claude Code product providers](2026-08-10-product-subagent-providers-in-shared-host.md), are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. **A real-composition test that disables a host row cannot audit that row.** The web composition test disabled `api-gateway` — the api-proxy itself — as a row with side effects, which is exactly the row whose pending injection would have named the break. It now boots with the api-proxy enabled and the browse directory picker substituted, so the boot audit covers the whole host-plane injection graph; only the port, the asset tree, and the telemetry exporter stay off. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 94651d4659..2845db46cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -56,7 +56,7 @@ Status: implemented **创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这三者被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list` 与 `select` 刻意保持为普通方法。名单只携带 id 与信任级别,而局域网客户端的选择器需要它;至于选择本身,它看起来像提权——其中一个 preset 会挂载可编辑活动运行时的工具集——但 `session.create` 本就接受 `agentPreset`,只固定切换会把同一能力留在隔壁一个方法上。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 -**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括固定的 Codex 与 Claude Code 产品 provider,都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 +**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括[固定的 Codex 与 Claude Code 产品 provider](2026-08-10-product-subagent-providers-in-shared-host.md),都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 **真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试把 `api-gateway`——也就是 api-proxy 本身——当作「有外部副作用的行」禁用了,而它恰恰是那个会以 pending 注入点名此次断裂的行。现在它在启用 api-proxy、并替换为 browse 目录选择器的前提下引导,启动审计因此覆盖整个宿主平面的注入图;只有端口、资源目录与遥测导出器仍然关闭。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml new file mode 100644 index 0000000000..b696b46749 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md +2026-08-10-product-subagent-providers-in-shared-host.md: 33b6eb6cf7a6c19e9ea71cdb7dc8881e8052ef24 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: fd78c7a3fee4e4ee30d27d87c752e1a23576fd85 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md new file mode 100644 index 0000000000..33b6eb6cf7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md @@ -0,0 +1,41 @@ +# Agent Note: Product subagent providers live in the shared profile host + +Status: implemented + +English | [中文](2026-08-10-product-subagent-providers-in-shared-host.zh.md) + +## Problem + +The [Codex and Claude Code provider contracts](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md) were first shipped as independently installable packages that a deployment loaded beside the common subagent tool. Agent Presets later became the ordinary owner of one agent's model-visible tools, but a preset cannot safely own these product providers: `ctx.subagents` is a process registry, provider names are unique, and host consumers resolve the same registry across sessions. Requiring a person to edit both a Profile and a Preset would also make a generic preset row incomplete by itself. + +The placement decision must preserve two independent facts. Loading a provider must not start or authenticate a product, while enabling a tool must remain per preset so two sessions can expose different products. A global product switch, a provider instance per agent, or pre-enumerated combination presets would each create a second owner for one of those facts. + +## Decision + +Every shipped Profile loads the fixed `codex` and `claude-code` providers once through the base bundle's host plane. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows for `subagent_codex` and `subagent_claude_code`, so a preset can expose neither tool, either one, or both without changing the provider registry. + +This decision supersedes only the opt-in composition placement recorded by the provider-contract note. That note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. + +The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, test authentication, or add product-specific settings. Missing commands and product failures remain local to the attempted delegation. + +The current base dependency closure still includes the Claude Agent SDK's optional platform CLI payload even though production resolves the host `claude`. Removing that unused payload belongs to the separate product installation-closure follow-up; this placement decision neither installs it dynamically nor treats it as the production executable. + +## Verification + +The base Loader test proves both provider names register exactly once and no product process starts during Profile boot. Real Agent Preset composition covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Keyless ACP snapshots pin the model-visible tool schemas for one and both products, while provider tests separately prove native executable resolution, failure, cancellation, and process-tree quiescence. + +## Alternatives considered + +**Keep product providers opt-in at the Profile layer.** This preserves a smaller default dependency closure, but a copied or agent-authored Preset row is not usable unless the person also discovers and edits a second composition layer. It leaves the general Preset entry incomplete for these otherwise ordinary tools. + +**Store global or per-Profile product enable switches.** A process switch competes with the Preset as owner of model-visible tools and cannot express two sessions using different combinations. Availability and authentication are deployment facts, not another persisted product state. + +**Mount a provider inside every Agent Preset.** Provider names belong to a process registry, so the second session would collide with the first. Host consumers also need the registry independently of any one agent's lifetime. + +**Ship four product-combination presets.** Four identities duplicate complete compositions to represent two independent tool rows. Ordinary rows already express the full matrix without adding roster or maintenance state. + +## Consequences + +A user manages both products through the same Agent Preset authoring path as other plugins, and each new session receives exactly the tools its chosen preset contributes. Every Profile carries two dormant provider registrations, so unused products consume package and module-loading footprint but no product process, login, model call, or product home. + +The Host registry remains the single provider authority and each Preset remains the single model-tool authority. The trade-off is the current Claude SDK optional-payload installation cost, which stays explicitly deferred rather than being hidden behind another enable state or installer lifecycle. diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md new file mode 100644 index 0000000000..fd78c7a3fe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 产品 subagent 提供方位于共享 profile 宿主 + +Status: implemented + +[English](2026-08-10-product-subagent-providers-in-shared-host.md) | 中文 + +## 问题 + +[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)最初以可独立安装的包交付,由部署环境在通用 subagent 工具旁加载。Agent Preset 后来成为单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有这些产品提供方:`ctx.subagents` 是进程级注册表,提供方名称唯一,而宿主消费方会跨会话解析同一个注册表。如果要求用户同时编辑 Profile 和 Preset,也会使通用 preset 行本身不完整。 + +归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具是否启用仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。 + +## 决策 + +每个随发行版交付的 Profile 都会通过 base 组合包的宿主平面,把固定的 `codex` 与 `claude-code` 提供方各加载一次。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 分别通过普通的 `dsh-tool-subagent` 行贡献 `subagent_codex` 与 `subagent_claude_code`,因此一个 preset 可以不暴露任何工具、只暴露其中一个或同时暴露两者,而无需更改提供方注册表。 + +本决策仅取代提供方约定说明所记录的、原先由用户选择启用的组装位置。该说明仍负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 + +这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本、测试身份验证,也不会新增产品专属设置。命令缺失和产品故障仍局限于发生问题的那次委派。 + +当前 base 依赖闭包仍包含 Claude Agent SDK 的可选平台 CLI(命令行界面)载荷,尽管生产环境解析的是宿主提供的 `claude`。移除这份未使用载荷属于独立的产品安装闭包后续项;本归属决策既不会动态安装它,也不会将它当作生产可执行文件。 + +## 验证 + +base Loader 测试证明两个提供方名称都恰好注册一次,而且 Profile 启动期间不会启动产品进程。真实 Agent Preset 组装覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。无密钥 ACP(Agent Client Protocol)快照固定单个产品与两个产品同时启用时的模型可见工具 schema,提供方测试则另行证明原生可执行文件解析、失败、取消和进程树完全停稳。 + +## 考虑过的替代方案 + +**将产品提供方保留为 Profile 层的按需启用项。** 这样可缩小默认依赖闭包,但复制或由 agent 创作的 Preset 行无法直接使用,除非用户还发现并编辑第二个组装层。对于这些本来与其他工具无异的工具,通用 Preset 入口仍不完整。 + +**存储全局或按 Profile 配置的产品启用开关。** 进程级开关会与 Preset 争夺模型可见工具的责任归属,也无法表示两个会话使用不同组合。可用性与身份验证属于部署事实,并非另一份需要持久化的产品状态。 + +**在每个 Agent Preset 内挂载一个提供方。** 提供方名称属于进程级注册表,因此第二个会话会与第一个冲突。宿主消费方也需要独立于任何单个 agent 的生命周期使用该注册表。 + +**交付四个产品组合 preset。** 四个身份会复制完整组装,只为表示两条独立的工具行。普通行已经能表达完整矩阵,无需新增名单或维护状态。 + +## 后果 + +用户通过与其他插件相同的 Agent Preset 创作路径管理两个产品,每个新会话只会获得其所选 preset 所贡献的工具。每个 Profile 都携带两个休眠的提供方注册,因此未使用的产品会产生包和模块加载开销,但不会启动产品进程、登录、调用模型或创建产品主目录。 + +宿主注册表仍是提供方的唯一权威,每个 Preset 仍是模型工具的唯一权威。代价是当前 Claude SDK 可选载荷的安装成本继续被明确延期处理,而不会隐藏在另一种启用状态或安装程序生命周期之后。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index cb5c2c6e14..84cb091651 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 40c622d85b427e2e85c160eb956aeae7d384ca65 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 85e71edb9139764a244277f731849d14ab1230d7 +2026-08-04-claude-code-and-codex-subagent-backends.md: ccc96d6c998c4ab958a7eea1e502d036d16ec90d +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 740eeb633e336d5b01cb0b84fb656612e690959d diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 40c622d85b..ccc96d6c99 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance ## Decision -The harness publishes two sibling one-shot providers in the shared profile host: `codex` and `claude-code`. Loading the host providers starts no product process. An Agent Preset independently contributes ordinary `dsh-tool-subagent` rows when its agent should see `subagent_codex`, `subagent_claude_code`, both, or neither; the shipped full presets carry both rows disabled so copies have one accurate configuration template without changing the default model schema. Each tool accepts only a standalone text task; product selection and background execution are not model arguments. +The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [shared-profile-host placement decision](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md) supersedes the original opt-in composition placement. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection and background execution are not model arguments. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools disable background execution and use `maxDepth: 'provider-managed'`, leaving recursion policy with the out-of-process product instead of sending a limit the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. @@ -65,7 +65,7 @@ The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its rea The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220 and a native Claude Code installation compatible with its query protocol. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. +The Claude Code evidence pins Agent SDK 0.3.220 and uses its platform-distributed Claude Code 2.1.220 CLI as the deterministic compatibility fixture, routed through the same native executable-resolution path production uses. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. This evidence proves the official SDK/CLI integration path, not compatibility with every independently installed product version. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. @@ -87,7 +87,7 @@ The project owner's distribution authorization is scoped to the official `@anthr ## Consequences -Users copy or author an Agent Preset and independently enable either or both stable foreground tools. Every profile host supplies the reusable providers once, while each preset owns only its agent's model-visible tool rows. Official product integrations preserve native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence. +Users delegate through two stable foreground tools backed by the official product integrations. Their Profile placement and per-Preset exposure are owned by the [shared-host placement decision](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md); this note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence. Every delegation pays for a fresh product process and independent model context, and only final text reaches the parent. Product-native configuration makes behavior depend on the deployment's installed product, account state, and workspace settings. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 85e71edb91..740eeb633e 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -harness 在共享 profile 宿主中交付两个一次性兄弟提供方:`codex` 与 `claude-code`。加载宿主提供方不会启动产品进程。某个 Agent Preset 是否让自己的 agent 看见 `subagent_codex`、`subagent_claude_code`、两者或两者皆无,由该 preset 独立贡献普通的 `dsh-tool-subagent` 行;随附的完整 preset 携带两条默认禁用的行,使复制品拥有一份准确配置模板,同时不改变默认模型 schema。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 +harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[共享 profile 宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)取代原先由用户选择启用的组装位置。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具会禁用后台执行,并使用 `maxDepth: 'provider-managed'`,将递归策略留给进程外产品,而不是发送提供方无法强制执行的限制。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 @@ -65,7 +65,7 @@ Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 Claude Code 安装。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 +Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 @@ -79,7 +79,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 **面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 -**以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。 +**以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture。 **由插件管理登录、产品主目录、模型、设置或权限。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 @@ -87,7 +87,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及与其查询协议兼容的原生 ## 后果 -用户可以复制或创作一个 Agent Preset,并分别启用任一或两个稳定前台工具。每个 profile 宿主只提供一次可复用 provider,而每个 preset 只拥有自己 agent 的模型可见工具行。官方产品集成会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 +用户通过官方产品集成支持的两个稳定前台工具进行委派。它们在 Profile 中的归属和按 Preset 暴露方式由[共享宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)负责;本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 每次委派都要承担新建产品进程和独立模型上下文的开销,且只有最终文本会到达父级。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index b8d67c0c67..02155a41ab 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -124,6 +124,10 @@ const SCENARIOS: Scenario[] = [ // text-turn is the default header pin and owns the prompt and tool-schema // sidecars reused by alternate classes with identical component sequences. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, + // Product-subagent scenarios are authored schema-isolation fixtures: they + // reuse the stable text-turn transcript so only Loader-composed headers and + // tool sidecars vary. Model output and usage are not evidence here, so record + // mode must not replace them with live-API output. { name: 'product-subagent-codex', hasModelTurn: true, diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 7d602ab7fd..efb4592fc8 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: d9dbf717d1d11adce2eae9498d4bfa596a46fd3e -README.zh.md: 9c0d47f7e4abc1b0636bd2097ea3e2bc8c826b81 +README.md: c3fc95b45a4f458387db7e0b4464cfca0ef82c6d +README.zh.md: 56e03c26625af1e34e3fde77ab3e07a6d91ad34a diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index d9dbf717d1..c3fc95b45a 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -19,4 +19,5 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. +- **Claude's SDK platform CLI remains in the Profile install closure** — the base bundle depends on the Claude provider, whose production path resolves the host `claude`; removing the SDK's unused optional payload is deferred to the product installation-closure follow-up. - **The Windows temp grant is a private per-session subdirectory** — `workspace-write` confines writes to the workspace plus the session's own temp subdirectory (`<temp>\dsh-<hash>`, TMP/TEMP rewritten for confined children); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 9c0d47f7e4..56e03c2662 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -19,4 +19,5 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 +- **Claude SDK 的平台 CLI(命令行界面)仍在 Profile 安装闭包中**:base 组合包依赖 Claude 提供方,其生产路径解析宿主提供的 `claude`;移除 SDK 中未使用的可选载荷,推迟到产品安装闭包后续项处理。 - **Windows 的临时目录授权是按会话的私有子目录**——`workspace-write` 把写入限制在工作区与会话自己的 temp 子目录(`<temp>\dsh-<hash>`,受限子进程的 TMP/TEMP 被改写);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 05d8cd5705..bbb3c9cf1a 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md -README.md: 7b19dc8e8f0e4bf05097dae15c9455f30bc7c998 -README.zh.md: 0dff24816024c0c44b9bdb532b5c7a3e5656fb3b +README.md: 17b14e847baea3eadda7129b5e49f5e65b668cc8 +README.zh.md: 2f59144d5bd9f26a58773e6dd53909b2b0e8da14 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 7b19dc8e8f..17b14e847b 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -29,7 +29,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | -Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data while the SDK's fixed arguments stay ordinary argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. +Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data. The pinned SDK's fixed flags then occupy cmd's command tail and contain no cmd metacharacters; they are not ordinary Windows argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. Shipped profiles load this provider once on the host and start no Claude process until a tool call. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. A custom host composition can still use both rows directly. @@ -52,7 +52,7 @@ Shipped profiles load this provider once on the host and start no Claude process ## Product compatibility and evidence -The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`. Production runs the native `claude` installation; the SDK's platform optional payload remains in the current installation closure and is tracked as a separate distribution follow-up. Required evidence exercises the compatible native product through a keyless loopback path and a credentialed DeepSeek path, while Loader composition proves that both product packages coexist without starting either product. +The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`. Production runs the native `claude` installation. The keyless real-product test uses the SDK-distributed Claude Code 2.1.220 CLI as a deterministic fixture, routed through the same native executable-resolution and Windows batch-shim path; it does not claim compatibility with every independently installed version. Loader composition proves that both product packages coexist without starting either product. The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate. @@ -91,6 +91,7 @@ Append-only: the new tool result follows the reusable parent request prefix. - **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode. - **Product installation and account state remain native** — a missing or incompatible `claude`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. +- **The SDK platform CLI remains in the install closure** — production ignores it in favor of the host `claude`, but the current SDK optional dependency is still installed and supplies the keyless compatibility fixture. Removing that payload belongs to the separate product installation-closure follow-up. - **No human interaction path** — `AskUserQuestion` is disabled and other interactive callbacks are absent, so tasks requiring new approval or input fail instead of suspending. - **Final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 0dff248160..2f59144d5b 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -29,7 +29,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据,而 SDK 的固定参数继续使用普通 argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 +生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据。锁定版本的 SDK 随后把固定命令行选项放在 cmd 的命令尾部;这些选项不含 cmd 元字符,也并不是普通的 Windows argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Claude 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。自定义宿主组装仍可直接使用两条配置行。 @@ -52,7 +52,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK ## 产品兼容性与证据 -运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`。生产运行使用原生 `claude` 安装;SDK 的平台可选载荷仍处于当前安装闭包,并作为独立分发后续项跟踪。强制证据会通过无密钥回环路径与带密钥 DeepSeek 路径运行兼容的原生产品,而 Loader 组合则证明两个产品包能够共存且不会启动任一产品。 +运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`。生产运行使用原生 `claude` 安装。无密钥真实产品测试使用由 SDK 分发的 Claude Code 2.1.220 CLI 作为确定性 fixture(测试前置数据),并通过同一套原生可执行文件解析路径与 Windows batch shim 路径运行;这项测试不声称兼容每个独立安装的版本。Loader 组合证明两个产品包能够共存且不会启动任一产品。 项目所有者按身份范围授权分发官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会把其声明条款归类为宽松许可证;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。 @@ -91,6 +91,7 @@ Claude Code 子任务会在一个全新的 SDK query 中接收独立文本任务 - **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 - **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 +- **SDK 平台 CLI 仍在安装闭包内**:生产环境会忽略它,改用宿主提供的 `claude`,但当前 SDK 的可选依赖仍会安装,并提供无密钥兼容性 fixture。移除该载荷属于独立的产品安装闭包后续项。 - **没有人工交互路径**:`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。 - **仅返回最终文本**:推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 03b9cfd2b3..1e2a259ca2 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -45,6 +45,8 @@ export function sdkEnvironmentOverlay( * @param graceMs - process-tree termination grace. * @param platform - host platform selecting the Windows batch-shim boundary. * @returns the fully explicit shared subprocess request. + * @remarks The batch-shim path quotes only the resolved executable. The pinned SDK + * supplies fixed flag arguments without cmd metacharacters; cmd reparses that tail. */ export function claudeSpawnSpec( options: SpawnOptions, diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 2a50d560a4..7c44d000e8 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -196,7 +196,7 @@ function startRequest( }) } -describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { +describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 fixture', { timeout: 60_000, }, () => { it('inherits host settings and sends the exact task and fake key to local Messages', async () => { From cc527dfa9aba44e0f253ea21dcf3e5b45ef5f712 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 17:02:11 +0800 Subject: [PATCH 096/229] fix(web): order preset before subagent list --- packages/client/ui-agent-preset/README.i18n.yaml | 4 ++-- packages/client/ui-agent-preset/README.md | 2 +- packages/client/ui-agent-preset/README.zh.md | 2 +- packages/client/ui-agent-preset/src/client/index.ts | 2 +- packages/client/ui-agent-preset/tests/apply.spec.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 6943e47673..b1314b349e 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: 32a4e7d9e25d3c70d2cc2e8a01c94d093d19659c -README.zh.md: b65a1bdf926f7a34bc3813833ca5ac2d3b6dfabd +README.md: 0f1daeaa6014d4c3c88e6a69ff90cf1ecacdbaf7 +README.zh.md: 08e25d9e98b83a94a434248bb3dff60da1cc31ba diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 32a4e7d9e2..0f1daeaa60 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -18,7 +18,7 @@ A session that has started is refused rather than queued: the host answers `agen ## The session-header label -A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads. +A third surface, beside the session title: the preset THIS session runs, as static chrome. It precedes the subagent catalog in the header action row. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads. ## What it reads and writes diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index b65a1bdf92..08e25d9e98 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -18,7 +18,7 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于 ## 会话标题旁的标签 -第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。 +第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。它在头部操作行中排在 subagent 列表之前。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。 ## 它读什么、写什么 diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 97a7f66ce2..0737992337 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -157,7 +157,7 @@ export function apply(ctx: ClientContext): void { const label = scope.slots.register({ name: 'conversation.session.header.actions', id: 'agent-preset', - order: 20, + order: 0, locale: 'settings.agentPreset', inject: labelInjected, }, AgentPresetLabel) diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index 6272501183..4a6f58075f 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -304,7 +304,7 @@ describe('ui-agent-preset apply', () => { expect(chip.component).toBe(AgentPresetSeat) const label = slots.entries('conversation.session.header.actions')[0]! expect(label.component).toBe(AgentPresetLabel) - expect(label.options).toMatchObject({ id: 'agent-preset', order: 20 }) + expect(label.options).toMatchObject({ id: 'agent-preset', order: 0 }) await fiber.dispose() expect(slots.entries('conversation.hero.agentPreset')).toHaveLength(0) expect(slots.entries('conversation.session.header.actions')).toHaveLength(0) From 0a4bdd8ee806a0eeaaf523dba3057717d710f58a Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Mon, 10 Aug 2026 17:06:07 +0800 Subject: [PATCH 097/229] 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 e53f44865065d4583c109c2bfea64439d9c9bd27 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 17:46:34 +0800 Subject: [PATCH 098/229] fix(subagent): compose children from their parent's preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool and prompt-section visibility is inherited along dsh-scope's parent chain, and an agent's scope key is minted with no parent. Per-session agent presets moved every model-facing row onto the agent plane and made AgentPresets.mount() the one thing that binds that link, from the api-proxy's session create, resume, and fork paths. The two in-process subagent drivers installed only the per-child persona and tool filter, so a child's scope chain had length one and its registry view resolved the global layer alone — which is empty wherever a preset roster is composed. One-shot children reached the model with no tools, continuable ones with only the host-plane `report`, and neither carried its parent's persona, workspace context, or skill catalog. AgentPresets.composeFrom() joins one agent to the standing composition another already runs on. It is a bind, not a mount: the child gets its parent's exact generation, so a composition edited since the parent started cannot fork it onto another one, and it is synchronous, which is what lets a child creation window use it. applyChildComposition() now takes the parent and performs the join first, making a child composed without it unrepresentable at the call sites. childSessionMeta() records the joined id so a cold read rebuilds the composition the child actually ran under. The audit that followed found two api-proxy readers on the wrong authority: presenterScopeFor() and the live-agent branch of assertPresetUnchanged() both read header.agentPreset, which goes stale the moment a blank session switches preset. A switched session's cold transcript resolved presenters in the older composition's layer and silently degraded to generic cards, and the gateway refused to adopt a live session under the preset it actually runs while accepting the one it left. Both now resolve through resolveSessionPreset(), matching the resume branch fifteen lines above. The owning architecture Agent Note carried the stale claim that the header records what a session runs; it is corrected to name the header/log pair and its three readers. Fixes #2165 --- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 2 +- ...2026-08-03-per-session-agent-presets.zh.md | 2 +- ...-agents-join-their-parent-preset.i18n.yaml | 6 + ...0-child-agents-join-their-parent-preset.md | 47 +++++++ ...hild-agents-join-their-parent-preset.zh.md | 47 +++++++ apps/cli/package.json | 1 + apps/cli/tests/web-agent-presets.e2e.ts | 54 ++++++++ docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 37 ++++++ docs/subsystems/core.zh.md | 37 ++++++ packages/host/apiproxy/src/api-proxy.ts | 24 ++-- .../tests/api-proxy-agent-preset.spec.ts | 39 ++++++ .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 10 ++ packages/preset/agent-presets/README.zh.md | 10 ++ packages/preset/agent-presets/src/index.ts | 54 +++++++- packages/preset/agent-presets/src/mount.ts | 36 ++++-- .../preset/agent-presets/tests/mount.spec.ts | 73 +++++++++++ .../tool-cordis/src/api-catalog.ts | 8 ++ .../subagent/subagent-inprocess/package.json | 3 + .../subagent/subagent-inprocess/src/index.ts | 2 +- .../tests/fixtures/plugins/preset-tool.js | 20 +++ .../fixtures/presets/coding/agent.cordis.yml | 5 + .../tests/preset-inheritance.spec.ts | 116 ++++++++++++++++++ packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 4 + packages/subagent/subagent/README.zh.md | 4 + packages/subagent/subagent/package.json | 5 + packages/subagent/subagent/src/child-agent.ts | 47 +++++-- .../subagent/subagent/src/continuation.ts | 2 +- packages/subagent/subagent/tsconfig.json | 3 + pnpm-lock.yaml | 15 +++ 36 files changed, 698 insertions(+), 41 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md create mode 100644 packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js create mode 100644 packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml create mode 100644 packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index f3d763058b..f9e1b3c024 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 6f1643c25008c3363cb10adb7fbff7afeea31cbe -2026-08-03-per-session-agent-presets.zh.md: 7afe9ade5c98fadb96384a7e0acd47531c370e0c +2026-08-03-per-session-agent-presets.md: c39117ab0de001650a95f98ccf3e42f3a5034c92 +2026-08-03-per-session-agent-presets.zh.md: 5e98a2865013a355c134317ded8a4f2ddaccf42c diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 6f1643c250..c39117ab0d 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -31,7 +31,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) ## Consequences -**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session header enforces from the other side — the header records the id a session actually runs, so a resume rebuilds that composition rather than today's default, and the gateway rejects an attempt to adopt a live session under a different one. A snapshot would make the two disagree at exactly the moment the setting changes. +**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session log enforces from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than today's default, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes. **A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 7afe9ade5c..5e98a28650 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -31,7 +31,7 @@ Status: implemented ## 后果 -**有效默认值在每次解析时读取,从不快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session header 从另一侧执行的同一条——header 记录会话实际运行的 id,因此恢复重建的是那份组装而不是当下的默认值,网关也会拒绝把一个活着的会话收编到另一个 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。 +**有效默认值在每次解析时读取,从不快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session 日志从另一侧执行的同一条——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是当下的默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。 **直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml new file mode 100644 index 0000000000..9afec2a879 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.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-child-agents-join-their-parent-preset.md +2026-08-10-child-agents-join-their-parent-preset.md: c9917c48d10c2b2515284405ea52aed8b476f8b1 +2026-08-10-child-agents-join-their-parent-preset.zh.md: 09e4de5292b65e50bb3973704fd803c819be9f1c diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md new file mode 100644 index 0000000000..c9917c48d1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md @@ -0,0 +1,47 @@ +# Agent Note: Child agents join their parent's preset composition + +Status: implemented + +English | [中文](2026-08-10-child-agents-join-their-parent-preset.zh.md) + +## Problem + +Tool and prompt-section visibility is inherited along `dsh-scope`'s parent chain, and an agent's scope key is minted with no parent. [Per-session agent presets](../architecture/2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane and made `AgentPresets.mount()` the one thing that binds that parent link — from the api-proxy's session create, resume, and fork paths. The two in-process subagent drivers compose their children through `applyChildComposition()`, which installed only the per-child persona and tool filter, so a child's scope chain had length one and its registry view resolved the global layer alone. + +That layer is now empty in any deployment with a preset roster: the web-app patch layer disables every host-plane tool row. A one-shot child therefore reached the model with zero tools, a continuable child with only the host-plane `report`, and neither carried its parent's persona, workspace context, plan-mode section, or skill catalog. The fork path had already been given the same treatment for the same reason; delegation had not. + +The child's durable header compounded it. `childSessionMeta()` recorded no preset, so a cold read of a child session resolved the deployment default — a tool set the child never ran under, which is exactly what the model-visible ⟺ logged rule exists to prevent. + +## Decision + +`AgentPresets.composeFrom(agentCtx, parentCtx)` joins one agent to the standing composition another already runs on, and returns the preset id joined. It locates the parent's mount through `standingMountFor()` — the agent's key is parented to its preset's standing key, the same relation `serviceForAgent()` reads — and binds the child's key to that same standing key, keeping the binding under the roster's sole re-link authority. A parent that joined no preset yields no join and no error, which is the rosterless deployment: its model-facing rows sit in the host composition, where the child already resolves them through the global layer. + +This is a bind, not a mount, and both differences are load-bearing. The child gets its parent's exact generation, so a composition file edited since the parent started cannot hand the child a different one than its parent's history was produced under, and a preset deleted since cannot fail a child whose parent keeps running. It is also synchronous, which is what lets the child creation windows use it — both in-process drivers compose inside a synchronous `setup`. + +`applyChildComposition(childCtx, parent, composition)` takes the parent and performs the join before applying the child's own registrations. The parameter is the point: it makes composing a child without the join unrepresentable at the call sites, rather than leaving each new driver to remember a second step. `childSessionMeta()` records the joined id through `AgentPresets.composedPreset()`, read from the parent's live scope chain rather than its header, because a parent that switched preset while blank runs on the newer composition while its header still names the older one. + +`dsh-subagent` reaches the roster through `ctx.get('agentPresets')` with a type-only import and an optional peer dependency — the documented opportunistic-consumption pattern it already uses for `sandboxPolicy` and `approval`. + +## Alternatives considered + +**Re-mount the parent's preset by id in the child's setup.** Rejected on both semantics and mechanics. It re-reads the roster and re-stats the composition file, so an edit since the parent started forks the child onto a different generation, and a preset deleted since fails the child while its parent runs on. `mount()` is also asynchronous, which the synchronous creation windows cannot accept without restructuring both drivers. + +**Bind the child's key to the PARENT's key rather than to the standing mount.** Rejected because it changes what a child inherits: the parent's own scope layer carries its per-agent restrictions, which would then intersect into every descendant, and a child outliving its parent would hang off a disposed agent's key. Joining the standing mount gives the child its parent's composition and nothing else. + +**Extend the continuable activation setup registry to cover one-shot children.** Rejected because that registry's contribution type is synchronous `(childCtx) => () => void` with per-installation revocation, modelling deployment capabilities that come and go, while a preset join is a one-time bind with no revocation of its own. Widening it would have made the omission possible again for any driver that skipped the registry. + +**Let `dsh-subagent` import `resolveSessionPreset` and mount by the resolved id.** Rejected because it makes the preset roster a hard module edge for a package that must work without one, and it lands back on the remount semantics above. + +**Leave the durable header alone and fix only the live join.** Rejected because the live child and the same child read cold would then disagree about which composition produced its history — the same class of defect, moved rather than fixed. + +## Testing + +`packages/preset/agent-presets/tests/mount.spec.ts` covers the join against real fixture compositions: the child sees its parent's tools and prompt sections, no second generation is mounted, the join survives the parent's disposal (a background child outliving its parent), the reported id matches, a parent without a preset joins nothing, and an unscoped context is refused. + +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, and a parent that switched preset while blank. + +## Consequences + +Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's, minus whatever its own `toolFilter` removes; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join. + +`applyChildComposition()` changed shape, so any future out-of-tree in-process driver must supply the parent. That is the intended cost: the previous signature let a caller compose a capability-less child and get no error. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md new file mode 100644 index 0000000000..09e4de5292 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md @@ -0,0 +1,47 @@ +# Agent Note: Child agents join their parent's preset composition + +Status: implemented + +[English](2026-08-10-child-agents-join-their-parent-preset.md) | 中文 + +## Problem + +工具与提示段的可见性沿 `dsh-scope` 的父链继承,而 agent 的 scope key 铸造出来时没有父。[逐会话 agent preset](../architecture/2026-08-03-per-session-agent-presets.md) 把所有面向模型的行搬到了 agent 平面,并让 `AgentPresets.mount()` 成为绑定那条父链的唯一途径——调用点在 api-proxy 的会话创建、恢复与 fork 路径上。两个进程内 subagent 驱动通过 `applyChildComposition()` 组装子 agent,而它只安装了逐子 agent 的 persona 与工具限制,于是子 agent 的 scope 链长度为一,其注册表视图只能解析到全局层。 + +在任何配置了 preset roster 的部署里,那一层现在是空的:web-app 补丁层禁用了全部宿主平面工具行。因此一次性子 agent 抵达模型时工具为零,可继续子 agent 只剩宿主平面的 `report`,两者都不带父方的 persona、工作区上下文、plan-mode 段与技能目录。fork 路径此前已因同一理由做过相同处理;委派没有。 + +子 agent 的持久化 header 让问题更进一步。`childSessionMeta()` 不记录任何 preset,于是冷读一个子会话解析到的是部署默认值——一套该子 agent 从未运行过的工具集,而这正是"模型可见 ⟺ 已记录"规则要杜绝的情形。 + +## Decision + +`AgentPresets.composeFrom(agentCtx, parentCtx)` 让一个 agent 加入另一个 agent 已在运行的常驻组装,并返回所加入的 preset id。它通过 `standingMountFor()` 定位父方的挂载——agent 的 key 认父到其 preset 的常驻 key,正是 `serviceForAgent()` 读取的同一关系——再把子 agent 的 key 绑到同一个常驻 key 上,绑定句柄仍归 roster 独有的重链权威持有。未加入任何 preset 的父方不产生加入、也不报错,那就是无 roster 的部署:它面向模型的行位于宿主组装中,子 agent 已经能通过全局层解析到它们。 + +这是认父而非挂载,两处差别都要紧。子 agent 拿到的是父方那个确切的代际,因此父方启动后被编辑过的组装文件不可能把与父方历史所产出时不同的另一个代际交给它,此后被删除的 preset 也不可能让一个父方仍在运行的子 agent 失败。它还是同步的,这正是子 agent 创建窗口能够使用它的前提——两个进程内驱动都在同步的 `setup` 中完成组装。 + +`applyChildComposition(childCtx, parent, composition)` 接收父方,并在应用子 agent 自身注册之前完成加入。这个参数正是要点所在:它让"组装子 agent 却不做该加入"在各调用点无法表达,而不是把第二个步骤留给每个新驱动去记住。`childSessionMeta()` 通过 `AgentPresets.composedPreset()` 记录所加入的 id,该值从父方**活着的** scope 链读取而不是从其 header 读取,因为在空白期切换过 preset 的父方运行在更新的那份组装上,而它的 header 仍写着旧的那个。 + +`dsh-subagent` 以类型级导入加可选 peer 依赖的方式,通过 `ctx.get('agentPresets')` 触达 roster——这正是它对 `sandboxPolicy` 与 `approval` 已在使用的、有明确文档的机会性消费模式。 + +## Alternatives considered + +**在子 agent 的 setup 里按 id 重新挂载父方的 preset。** 语义与机制两方面都不成立而被否决。它会重读 roster 并重新 stat 组装文件,因此父方启动后的一次编辑就会把子 agent 分叉到另一个代际,而此后被删除的 preset 会让子 agent 失败、父方却照常运行。`mount()` 还是异步的,同步的创建窗口无法在不重构两个驱动的前提下接受它。 + +**把子 agent 的 key 绑到**父方的** key 而不是常驻挂载上。** 否决,因为这改变了子 agent 继承的内容:父方自己的 scope 层携带其逐 agent 限制,那些限制会就此与每个后代求交,而活得比父方久的子 agent 会挂在一个已 dispose 的 agent key 上。加入常驻挂载给到子 agent 的是父方的组装,仅此而已。 + +**扩展可继续 activation setup 注册表以覆盖一次性子 agent。** 否决,因为该注册表的贡献类型是同步的 `(childCtx) => () => void` 并带有逐次安装的撤销,建模的是会来会走的部署能力,而 preset 加入是一次性认父、自身没有撤销可言。扩展它反而会让任何绕过该注册表的驱动重新具备遗漏的可能。 + +**让 `dsh-subagent` 导入 `resolveSessionPreset` 并按解析出的 id 挂载。** 否决,因为这会给一个必须在没有 roster 时也能工作的包引入硬模块边,而且最终仍落回上述的重新挂载语义。 + +**只修活着的加入,不动持久化 header。** 否决,因为那样活着的子 agent 与冷读同一个子 agent 会对"哪份组装产出了这段历史"给出不同答案——同一类缺陷,只是被搬了个地方而不是被修掉。 + +## Testing + +`packages/preset/agent-presets/tests/mount.spec.ts` 用真实 fixture 组装覆盖该加入:子 agent 看到父方的工具与提示段、不会挂载出第二个代际、加入在父方 dispose 后依然成立(活得比父方久的后台子 agent)、上报的 id 一致、没有 preset 的父方不产生加入、以及无 scope 的上下文被拒绝。 + +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset,以及在空白期切换过 preset 的父方。 + +## Consequences + +委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力,减去它自己的 `toolFilter` 所移除的部分;逐 subagent 的 preset("agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。 + +`applyChildComposition()` 的形态变了,因此将来任何仓库外的进程内驱动都必须提供父方。这是刻意付出的代价:此前的签名允许调用方组装出一个毫无能力的子 agent 而不报任何错。 diff --git a/apps/cli/package.json b/apps/cli/package.json index d312dd28d9..03bd4c3db9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -77,6 +77,7 @@ "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@types/js-yaml": "^4.0.9", diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 1bfaed8c67..004de203c8 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -11,6 +11,7 @@ import type { PatchOptions } from '@cordisjs/plugin-include' import { beforeAll, describe, expect, it } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' +import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' import { CallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-tools' @@ -422,6 +423,59 @@ describe('a forked session', () => { }) }) +describe('a delegated child', () => { + it('runs on the composition its parent runs on', async () => { + const parent = await ctx.agents.create({ + sessionId: SessionId('preset-child-parent'), + meta: { agentPreset: 'standard' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + // Exactly what an in-process subagent driver's creation window does. + const child = await parent.agent.ctx.agents.create({ + sessionId: SessionId('preset-child'), + meta: childSessionMeta(parent.agent, 1, 0), + setup: (agentCtx) => { + applyChildComposition(agentCtx, parent.agent, {}) + }, + }) + try { + expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent)) + // The shipped `standard` preset is the whole coding agent; an empty + // child here is the defect, and equality alone would not catch it. + expect(toolNames(ctx, child.agent)).toContain('bash') + expect(child.agent.session.header.agentPreset).toBe('standard') + } finally { + await child.dispose() + await parent.dispose() + } + }) + + it('follows a parent that switched preset while blank', async () => { + const parent = await ctx.agents.create({ + sessionId: SessionId('preset-child-switch-parent'), + meta: { agentPreset: 'standard' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + await ctx.agentPresets.recompose(parent.agent.ctx, 'minimal') + const child = await parent.agent.ctx.agents.create({ + sessionId: SessionId('preset-child-switch'), + meta: childSessionMeta(parent.agent, 1, 0), + setup: (agentCtx) => { + applyChildComposition(agentCtx, parent.agent, {}) + }, + }) + try { + // The live scope chain is the authority, not the parent's creation + // header — which still names `standard`. + expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent)) + expect(child.agent.session.header.agentPreset).toBe('minimal') + } finally { + await child.dispose() + await parent.dispose() + } + }) +}) + describe('authoring a preset on the shipped composition', () => { let authorCtx: Context let userRoot: string diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 66486e97b7..b24ae41404 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: a2407f5d394020834172288e3d03518d1e8045db -module-graph.zh.md: 364033c29d773a764ce3f8f0036edeac7c9e0b21 +module-graph.md: 398b49ff2aa795c377abe19bf7a8649078aa0585 +module-graph.zh.md: 5cfcc612b27d64e098a51fda326190fdae1d7e7e diff --git a/docs/module-graph.md b/docs/module-graph.md index a2407f5d39..398b49ff2a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -822,6 +822,7 @@ flowchart TD pkg_command_compact --> pkg_compact pkg_command_compact --> pkg_invariants pkg_subagent --> pkg_agent + pkg_subagent --> pkg_agent_presets pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_llm @@ -1386,7 +1387,7 @@ flowchart TD | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 364033c29d..5cfcc612b2 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -824,6 +824,7 @@ flowchart TD pkg_command_compact --> pkg_compact pkg_command_compact --> pkg_invariants pkg_subagent --> pkg_agent + pkg_subagent --> pkg_agent_presets pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_llm @@ -1388,7 +1389,7 @@ flowchart TD | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 0b2b87a954..7b5ba07cd7 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: af27484160769156836f377e5b3aba2521280005 -core.zh.md: 12935f4d881f371cfe2c3c5bed85ef88f57ec71a +core.md: b66d07194cbdb44037d4ea3a972f4646b7854c52 +core.zh.md: ce95df4d88160e0ffc9c0031a7aad47e72a4b4f1 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index af27484160..b66d07194c 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -419,6 +419,43 @@ async resolve(id?: string): Promise<AgentPreset> */ async mount(agentCtx: Context, id?: string): Promise<AgentPreset> +/** + * Join one agent to the SAME standing composition another already runs on. + * + * This is how a child agent inherits its parent's capabilities. It is a bind, + * not a mount: the parent's generation is already composed, so the child gets + * that exact instance — the same plugin objects, the same tool registrations, + * the same prompt sections. Re-resolving the parent's preset by id instead + * would re-read the roster, and a composition file edited since the parent + * started would hand the child a DIFFERENT generation than the one its + * parent's history was produced under (and a preset deleted since would fail + * the child outright while its parent keeps running). + * + * Synchronous and infallible for that reason, which is what lets a child + * creation window use it: the two in-process subagent drivers compose their + * children inside a synchronous `setup`. + * + * A parent that joined no preset — a rosterless deployment — yields no join + * and no error: there, the model-facing rows sit in the host composition and + * the child already sees them through the global layer. + * @param agentCtx - the joining agent's scope context. + * @param parentCtx - the scope context of the agent whose composition to join. + * @returns the preset id joined, or undefined when the parent joined none. + * @throws when `agentCtx` carries no scope, or has already joined a preset. + */ +composeFrom(agentCtx: Context, parentCtx: Context): string | undefined + +/** + * The preset one live agent runs on. + * + * Read from the live scope chain rather than from the session, so it answers + * for an agent whose session has not recorded a preset yet — a child agent + * whose durable header is being built from its parent's composition. + * @param agentCtx - the agent's scope context. + * @returns the preset id, or undefined when the agent joined none. + */ +composedPreset(agentCtx: Context): string | undefined + /** * Read one preset's composition text. * @param id - the preset id. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 12935f4d88..ce95df4d88 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -427,6 +427,43 @@ async resolve(id?: string): Promise<AgentPreset> */ async mount(agentCtx: Context, id?: string): Promise<AgentPreset> +/** + * Join one agent to the SAME standing composition another already runs on. + * + * This is how a child agent inherits its parent's capabilities. It is a bind, + * not a mount: the parent's generation is already composed, so the child gets + * that exact instance — the same plugin objects, the same tool registrations, + * the same prompt sections. Re-resolving the parent's preset by id instead + * would re-read the roster, and a composition file edited since the parent + * started would hand the child a DIFFERENT generation than the one its + * parent's history was produced under (and a preset deleted since would fail + * the child outright while its parent keeps running). + * + * Synchronous and infallible for that reason, which is what lets a child + * creation window use it: the two in-process subagent drivers compose their + * children inside a synchronous `setup`. + * + * A parent that joined no preset — a rosterless deployment — yields no join + * and no error: there, the model-facing rows sit in the host composition and + * the child already sees them through the global layer. + * @param agentCtx - the joining agent's scope context. + * @param parentCtx - the scope context of the agent whose composition to join. + * @returns the preset id joined, or undefined when the parent joined none. + * @throws when `agentCtx` carries no scope, or has already joined a preset. + */ +composeFrom(agentCtx: Context, parentCtx: Context): string | undefined + +/** + * The preset one live agent runs on. + * + * Read from the live scope chain rather than from the session, so it answers + * for an agent whose session has not recorded a preset yet — a child agent + * whose durable header is being built from its parent's composition. + * @param agentCtx - the agent's scope context. + * @returns the preset id, or undefined when the agent joined none. + */ +composedPreset(agentCtx: Context): string | undefined + /** * Read one preset's composition text. * @param id - the preset id. diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 5576f2e75c..a1f98d4f28 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -33,6 +33,7 @@ import { PresetNotWritableError, resolveSessionPreset, SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' +import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-tools' import type { ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame, @@ -1350,17 +1351,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * The registry view scope a transcript's presenters resolve in. * * A live agent is that scope itself (its chain passes through its preset's - * standing layer). A cold session names its preset on the header, and the + * standing layer). A cold session resolves its preset from the LOG, and the * preset's STANDING key serves without resuming anything — ensuring the * mount composes plugins but starts no agent, session, or turn. No roster, * no recorded preset, or a preset the roster no longer supplies all fall * back to the global layer: the transcript still serves, with the generic * cards a viewless entry renders. + * + * Reading the header alone would render a session that switched while blank + * through the composition it was CREATED with. Every tool only the newer + * preset registers resolves to no presenter there, and the transcript + * silently degrades to generic cards for exactly the calls its history is + * made of. * @param sessionId - the transcript being read. - * @param header - that session's header (attached or inspected). + * @param session - that session's header and log (attached or inspected). * @returns the scope to pass to presenter lookups, or undefined for global. */ - async function presenterScopeFor(sessionId: SessionId, header: SessionHeader): Promise<ScopeKey | undefined> { + async function presenterScopeFor( + sessionId: SessionId, + session: PresetBearingSession, + ): Promise<ScopeKey | undefined> { const live = ctx.get('agents')?.get(sessionId) if (live !== undefined) return live const presets = ctx.get('agentPresets') @@ -1370,7 +1380,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // through the DEFAULT preset's standing layer: that is the composition // an unnamed session composes today, and presenters are pure display, // so the worst a mismatch produces is the generic card it had anyway. - return await presets.standingKeyFor(header.agentPreset) + return await presets.standingKeyFor(resolveSessionPreset(session)) } catch { // Swallows only the unknown/unusable-preset rejection from the roster: // a deleted or broken preset must degrade this read, never fail it. @@ -1463,7 +1473,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // Beside the cwd check for the same reason, and after the await so it // covers every path that yields a live agent — freshly created, adopted // live, resumed from disk, or recovered by the concurrent-creation catch. - assertPresetUnchanged(sessionId, presetId, agent.session.header.agentPreset) + assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session)) if (agent.session.header.cwd !== cwd) { throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd) } @@ -2003,7 +2013,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state.header)) + const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state)) return ok(request, { events: page.events, hasMore: page.hasMore, @@ -2982,7 +2992,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // The scope presenters resolve in — the live agent, else the recorded // preset's standing key, else the global layer — so a cold session's // '/' popup lists the catalog its composition actually serves. - const scope = await presenterScopeFor(sessionId, session.header) + const scope = await presenterScopeFor(sessionId, session) try { const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable) return ok(request, { diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index eb707c01f6..996af59986 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -186,6 +186,24 @@ describe('session.create with an agent preset', () => { }) }) + it('adopts a live session under the preset it SWITCHED to', async () => { + const { api, ctx } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' })) + // Exactly what `agentPreset.select` leaves behind on a blank session: the + // header keeps the creation fact, the log states what the agent runs. + ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' }) + + const adopted = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'minimal' })) + const stale = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' })) + + // Comparing against the header would invert both answers: the preset the + // session actually runs would be refused, and the one it left would pass. + expect(adopted.result.ok).toBe(true) + expect(stale.result.ok).toBe(false) + if (stale.result.ok) throw new Error('unreachable') + expect(stale.result.error.details).toMatchObject({ existingPreset: 'minimal' }) + }) + it('adopts a live session unchanged when the caller names no preset', async () => { const { api } = await harness(['standard', 'minimal']) await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'minimal' })) @@ -660,6 +678,27 @@ describe('session.history presenter scope', () => { expect(standingKeyRequests).toEqual([]) }) + it('resolves a switched session from the LOG, not its creation header', async () => { + // The header is a creation fact; a switch while blank is a logged event, + // and every turn after it ran under the newer composition. Reading the + // header would render that history through the older preset's layer, + // where the tools it is made of have no presenter at all. + const meta = { id: SessionId('p4'), createdAt: 1, cwd: '/tmp/p4', agentPreset: 'standard' } + const { api } = await harness(['standard', 'minimal'], { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ + meta, + events: [{ type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } }], + }), + }) + + standingKeyRequests.length = 0 + const response = await api.sessions.history(request({ sessionId: SessionId('p4') })) + + expect(response.result.ok).toBe(true) + expect(standingKeyRequests).toEqual(['minimal']) + }) + it('serves a COLD transcript whose standing mount is no longer usable', async () => { // A genuinely cold session: persistence knows it, no live agent exists. const meta = { id: SessionId('p3'), createdAt: 1, cwd: '/tmp/p3', agentPreset: 'standard' } diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 65af853308..9c7f2c54ad 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: b6d469b26a0254adc654e5cc49d3df2d10817b2d -README.zh.md: 60c7bc695c27bf2c0169a0e405aa84aa711b9b21 +README.md: 5ccf1d7b224d0e3a67b3aeb9dc6679d6e802063f +README.zh.md: ed79cf48b96ed927feec8860b6211cedc369cdda diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index b6d469b26a..5ccf1d7b22 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -14,6 +14,8 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal - `ctx.agentPresets.list(): Promise<AgentPreset[]>` Every preset the configured roots currently supply, earlier root winning a duplicate id; broken presets included, each carrying its reason. - `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. A broken preset resolves — deleting, reading, and reporting one all need the row. - `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved. +- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and cannot fail. +- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built. - `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`. - `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`. - `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all. @@ -27,6 +29,14 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the join installed while the agent is still unpublished, so a rejected composition rolls the whole creation back rather than leaving a half-composed session. The standing subtree is owned by the roster service's own fiber — deliberately its UNTRACED context, because a subtree minted from a traced `this.ctx` resolves every service through the caller's shadow fiber instead of each entry's own inject store — so it survives every agent and unwinds only with the whole tree. Each generation records its composition file's stamp (mtime and size): a session that finds the stamp stale starts the next generation, while every session already joined keeps the one it runs on — the composition a running session joined outlives its file changing or disappearing underneath it, and files are the only composition editor, so the stamp is what carries an edit to later sessions. +### Composing a child agent + +A subagent's child joins its parent's standing composition through `composeFrom()`, never through `mount()`. Every model-facing row lives on the agent plane, so the tool registry's global layer is empty and a child that joins nothing reaches the model with no tools at all and none of its parent's prompt sections. + +Re-mounting the parent's preset by id would differ from the bind in two ways that both matter. A composition file edited since the parent started would hand the child a DIFFERENT generation than the one its parent's history was produced under, and a preset deleted since would fail the child outright while its parent keeps running. The bind is also synchronous, which is what lets the in-process subagent drivers use it at all — they compose their children inside a synchronous creation window. + +The child records the joined id on its own durable header ([`dsh-subagent`](../../subagent/subagent/README.md)), so a cold read of the child's history rebuilds the composition it actually ran under rather than the deployment default. + ### Which preset a session runs The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 60c7bc695c..ed79cf48b9 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -14,6 +14,8 @@ - `ctx.agentPresets.list(): Promise<AgentPreset[]>` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出;损坏的 preset 也在其中,各自携带原因。 - `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。损坏的 preset 照样解析——删除、读取与上报都需要这一行。 - `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。对损坏的 preset 直接以发现时记下的原因拒绝,所以每种不可加载的形态都在加载器介入之前以同一方式失败。 +- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` 让一个 agent 加入另一个 agent 已在运行的常驻组装,返回所加入的 preset id——父方未加入任何 preset 时返回 `undefined`,那是无 roster 的部署,不是错误。这是认父而非挂载,因此同步且不会失败。 +- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。 - `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。 @@ -27,6 +29,14 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,认父是在 agent 尚未发布时完成的,因此组装被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。常驻子树归 roster 服务自己的 fiber 所有——刻意用其未追踪的上下文,因为从被追踪的 `this.ctx` 派生的子树会经调用方的 shadow fiber 解析一切服务、无视各 entry 自己的 inject store——所以它比任何 agent 都活得久,只随整棵树卸载。每个代际记录其组装文件的 stamp(mtime 与大小):发现 stamp 过期的会话会开启下一个代际,而所有已加入的会话保持各自正在运行的那个——正在运行的会话所加入的组装在其文件被修改或删除后继续存活;文件是唯一的组装编辑器,stamp 正是把编辑送达后续会话的机制。 +### 组装子 agent + +subagent 的子 agent 通过 `composeFrom()` 加入其父方的常驻组装,绝不走 `mount()`。所有面向模型的行都在 agent 平面,工具注册表的全局层是空的,因此没有加入任何组装的子 agent 抵达模型时既没有任何工具,也没有父方的任何提示段。 + +按 id 重新挂载父方的 preset 与认父有两处差别,且两处都要紧。父方启动后被编辑过的组装文件会把与父方历史所产出时**不同**的一个代际交给子 agent;而此后被删除的 preset 会让子 agent 直接失败,尽管其父方仍在正常运行。认父还是同步的,这正是进程内 subagent 驱动能够使用它的前提——它们在同步的创建窗口里组装子 agent。 + +子 agent 会把所加入的 id 记在自己的持久化 header 上(见 [`dsh-subagent`](../../subagent/subagent/README.md)),因此冷读子 agent 的历史时重建的是它实际运行过的组装,而不是部署默认值。 + ### 会话实际运行的是哪个 preset 创建头部记录的是会话**以什么开始**,`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都走解析,而非直接读头部。 diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 58f523c4e3..0fb428c425 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -28,7 +28,7 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { discoverPresets } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' -import { mountPreset, serviceForAgent } from './mount.ts' +import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' import { PresetExistsError } from './authoring.ts' import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './types.ts' @@ -51,8 +51,8 @@ export { METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata, } from './metadata.ts' export { - inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, - type PresetMount, + inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, standingMountFor, + type JoinedPresetMount, type PresetMount, } from './mount.ts' export { copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError, @@ -238,6 +238,54 @@ export class AgentPresets extends Service { return preset } + /** + * Join one agent to the SAME standing composition another already runs on. + * + * This is how a child agent inherits its parent's capabilities. It is a bind, + * not a mount: the parent's generation is already composed, so the child gets + * that exact instance — the same plugin objects, the same tool registrations, + * the same prompt sections. Re-resolving the parent's preset by id instead + * would re-read the roster, and a composition file edited since the parent + * started would hand the child a DIFFERENT generation than the one its + * parent's history was produced under (and a preset deleted since would fail + * the child outright while its parent keeps running). + * + * Synchronous and infallible for that reason, which is what lets a child + * creation window use it: the two in-process subagent drivers compose their + * children inside a synchronous `setup`. + * + * A parent that joined no preset — a rosterless deployment — yields no join + * and no error: there, the model-facing rows sit in the host composition and + * the child already sees them through the global layer. + * @param agentCtx - the joining agent's scope context. + * @param parentCtx - the scope context of the agent whose composition to join. + * @returns the preset id joined, or undefined when the parent joined none. + * @throws when `agentCtx` carries no scope, or has already joined a preset. + */ + composeFrom(agentCtx: Context, parentCtx: Context): string | undefined { + const agentKey = scopeOf(agentCtx) + if (agentKey === undefined) { + throw new Error('agent-presets: refusing to compose an unscoped context; the scope key is what joins an agent to its preset') + } + const standing = standingMountFor(parentCtx) + if (standing === undefined) return undefined + this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) + return standing.presetId + } + + /** + * The preset one live agent runs on. + * + * Read from the live scope chain rather than from the session, so it answers + * for an agent whose session has not recorded a preset yet — a child agent + * whose durable header is being built from its parent's composition. + * @param agentCtx - the agent's scope context. + * @returns the preset id, or undefined when the agent joined none. + */ + composedPreset(agentCtx: Context): string | undefined { + return standingMountFor(agentCtx)?.presetId + } + /** Whether this deployment configures a root locally authored presets go to. */ get authorable(): boolean { return this.config.roots.some(root => root.trust === 'user') diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index eb890255ca..e968a97c25 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -202,6 +202,33 @@ export function leakedServices(ctx: Context, mount: Fiber): string[] { return leaked.sort((left, right) => left.localeCompare(right)) } +/** A live standing mount located through one agent already joined to it. */ +export type JoinedPresetMount = PresetMount & { + /** The standing key, definite because it is what the lookup matched on. */ + readonly key: ScopeKey +} + +/** + * The standing composition one agent is joined to. + * + * The agent's own key is parented to its preset's standing key, so the mount + * is found by matching that parent rather than by walking up from the agent — + * the mount is not under the agent's fiber. An agent that joined no preset — + * a deployment composing no roster, or a child agent before its join — has no + * parent link and resolves to undefined. + * @param agentCtx - the agent's scope context. + * @returns the mount the agent joined, or undefined when it joined none. + */ +export function standingMountFor(agentCtx: Context): JoinedPresetMount | undefined { + const agentKey = scopeOf(agentCtx) + if (agentKey === undefined) return undefined + const standingKey = scopeParentOf(agentKey) + if (standingKey === undefined) return undefined + return livePresetMounts().find( + (candidate): candidate is JoinedPresetMount => candidate.key === standingKey, + ) +} + /** * One agent's instance of a service its preset mounted. * @@ -231,14 +258,7 @@ export function serviceForAgent<K extends string & keyof Context>( agent: { ctx: Context }, name: K, ): Context[K] | undefined { - // The agent's own key is parented to its preset's standing key; the mount - // is no longer under the agent's fiber, so the search roots at the standing - // mount instead of walking up from the agent. - const agentKey = scopeOf(agent.ctx) - if (agentKey === undefined) return undefined - const standingKey = scopeParentOf(agentKey) - if (standingKey === undefined) return undefined - const mount = livePresetMounts().find(candidate => candidate.key === standingKey) + const mount = standingMountFor(agent.ctx) if (mount === undefined) return undefined const store = ctx.reflect.store for (const key of Object.getOwnPropertySymbols(store)) { diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 77c549a689..afe297a406 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -153,6 +153,79 @@ describe('composing an agent from a preset', () => { }) }) +describe('composing a child agent from its parent', () => { + /** Create one agent joined to `parent`'s composition, as a child creation window does. */ + async function childOf(ctx: Context, id: string, parent: Agent): Promise<Agent> { + const handle = await ctx.agents.create({ + sessionId: SessionId(id), + setup: (childCtx: Context) => void ctx.agentPresets.composeFrom(childCtx, parent.ctx), + }) + return handle.agent + } + + it('gives the child its parent\'s tools and prompt sections', async () => { + const parent = await agentOn(ctx, 'sess-parent', 'standard') + + const child = await childOf(ctx, 'sess-child', parent) + + expect(toolNames(ctx, child)).toEqual(['alpha']) + const prompt = await ctx.systemPrompt.assemble(assembleContextFor(child)) + expect(prompt.sections.map(section => section.name)).toContain('preset:alpha') + }) + + it('joins the parent\'s own generation rather than remounting its preset', async () => { + const parent = await agentOn(ctx, 'sess-shared', 'standard') + const before = livePresetMounts().length + + await childOf(ctx, 'sess-shared-child', parent) + + // A remount would compose a second copy of every row in the preset; the + // child must run on the plugin instances its parent already runs on. + expect(livePresetMounts()).toHaveLength(before) + }) + + it('keeps the child composed after its parent is disposed', async () => { + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('sess-dying-parent'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + const child = await childOf(ctx, 'sess-orphan', parentHandle.agent) + + await parentHandle.dispose() + + // Standing mounts outlive the agents that joined them, so a child outliving + // its parent — a background subagent — keeps the composition it started on. + expect(toolNames(ctx, child)).toEqual(['alpha']) + }) + + it('reports the preset id the child joined, for the durable header', async () => { + const parent = await agentOn(ctx, 'sess-named', 'minimal') + + const child = await childOf(ctx, 'sess-named-child', parent) + + expect(ctx.agentPresets.composedPreset(parent.ctx)).toBe('minimal') + expect(ctx.agentPresets.composedPreset(child.ctx)).toBe('minimal') + }) + + it('composes nothing when the parent joined no preset', async () => { + // The rosterless deployment: model-facing rows sit in the host composition + // and the child already resolves them through the registry's global layer. + const bare = (await ctx.agents.create({ sessionId: SessionId('sess-bare-parent') })).agent + + const child = await childOf(ctx, 'sess-bare-child', bare) + + expect(ctx.agentPresets.composedPreset(bare.ctx)).toBeUndefined() + expect(ctx.agentPresets.composeFrom(child.ctx, bare.ctx)).toBeUndefined() + expect(toolNames(ctx, child)).toEqual([]) + }) + + it('refuses to compose an unscoped context', async () => { + const parent = await agentOn(ctx, 'sess-unscoped-parent', 'standard') + + expect(() => ctx.agentPresets.composeFrom(ctx, parent.ctx)).toThrow(/unscoped context/) + }) +}) + describe('rejecting a composition that cannot be used', () => { it('refuses to mount into a context that carries no agent scope', async () => { await expect(ctx.agentPresets.mount(ctx, 'standard')) diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index fdb852533d..907c3bd266 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -110,6 +110,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async mount(agentCtx: Context, id?: string): Promise<AgentPreset>', jsDoc: '/**\n * Compose one agent from a preset: ensure the preset\'s standing mount, then\n * parent the agent\'s scope key to it so the mount\'s registrations and\n * listeners cover this agent.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken preset never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the preset that was composed, for the caller to record.\n * @throws when the preset is unknown or its composition is unusable.\n */', }, + { + signature: 'composeFrom(agentCtx: Context, parentCtx: Context): string | undefined', + jsDoc: '/**\n * Join one agent to the SAME standing composition another already runs on.\n *\n * This is how a child agent inherits its parent\'s capabilities. It is a bind,\n * not a mount: the parent\'s generation is already composed, so the child gets\n * that exact instance — the same plugin objects, the same tool registrations,\n * the same prompt sections. Re-resolving the parent\'s preset by id instead\n * would re-read the roster, and a composition file edited since the parent\n * started would hand the child a DIFFERENT generation than the one its\n * parent\'s history was produced under (and a preset deleted since would fail\n * the child outright while its parent keeps running).\n *\n * Synchronous and infallible for that reason, which is what lets a child\n * creation window use it: the two in-process subagent drivers compose their\n * children inside a synchronous `setup`.\n *\n * A parent that joined no preset — a rosterless deployment — yields no join\n * and no error: there, the model-facing rows sit in the host composition and\n * the child already sees them through the global layer.\n * @param agentCtx - the joining agent\'s scope context.\n * @param parentCtx - the scope context of the agent whose composition to join.\n * @returns the preset id joined, or undefined when the parent joined none.\n * @throws when `agentCtx` carries no scope, or has already joined a preset.\n */', + }, + { + signature: 'composedPreset(agentCtx: Context): string | undefined', + jsDoc: '/**\n * The preset one live agent runs on.\n *\n * Read from the live scope chain rather than from the session, so it answers\n * for an agent whose session has not recorded a preset yet — a child agent\n * whose durable header is being built from its parent\'s composition.\n * @param agentCtx - the agent\'s scope context.\n * @returns the preset id, or undefined when the agent joined none.\n */', + }, { signature: 'async read(id: string): Promise<string>', jsDoc: '/**\n * Read one preset\'s composition text.\n * @param id - the preset id.\n * @returns the composition exactly as stored.\n * @throws when no configured root supplies that id.\n */', diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index e18752db74..36646e7f7a 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -45,9 +45,12 @@ } }, "devDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index acb4e4d36e..ee7779447a 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -125,7 +125,7 @@ export async function startInProcessRun( if (inheritedPolicy !== undefined) { childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' }) } - applyChildComposition(childCtx, { + applyChildComposition(childCtx, parent, { persona: request.persona, toolFilter: request.toolFilter, }) diff --git a/packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js b/packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js new file mode 100644 index 0000000000..6fb224094d --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/fixtures/plugins/preset-tool.js @@ -0,0 +1,20 @@ +// A preset row standing in for the agent-plane tool rows a real preset mounts. +// Import-free on purpose — the Loader resolves entry modules through Node's ESM +// resolver, which cannot see this workspace's TypeScript sources. +export const name = 'preset-tool' +export const inject = ['tools', 'systemPrompt'] + +export function apply(ctx, config) { + ctx.effect(() => ctx.tools.register({ + name: config.tool, + description: `fixture tool ${config.tool}`, + parameters: { type: 'object', properties: {}, additionalProperties: false }, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }, + execute: () => Promise.resolve(config.tool), + })) + ctx.effect(() => ctx.systemPrompt.section({ + name: `preset:${config.tool}`, + order: 10, + text: `section for ${config.tool}`, + })) +} diff --git a/packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml b/packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml new file mode 100644 index 0000000000..a801659220 --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/fixtures/presets/coding/agent.cordis.yml @@ -0,0 +1,5 @@ +# Agent-plane composition: the model-facing row lives here, not in the host. +- id: only + name: ../../plugins/preset-tool.js + config: + tool: preset_only diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts new file mode 100644 index 0000000000..01c190a833 --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -0,0 +1,116 @@ +/** + * Composition inheritance: a child runs on the preset its parent runs on. + * + * With every model-facing row on the agent plane, the tool registry's global + * layer is empty, so a child that joins no preset reaches the model with no + * tools at all. These assert the model-visible result — the schemas in the + * child's own request — rather than the join that produces it. + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import AgentPresets from '@deepseek-ai/dsh-agent-presets' +import { SessionId } from '@deepseek-ai/dsh-session' +import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { startInProcessRun } from '../src/index.ts' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const ROOTS = [{ path: join(FIXTURES, 'presets'), trust: 'system' as const }] + +const contexts: Context[] = [] + +afterEach(async () => { + for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose() +}) + +/** A host composition carrying no model-facing rows, plus the preset roster. */ +async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; parent: Agent }> { + const ctx = new Context() + contexts.push(ctx) + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS }) + const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')]) + ctx.llm.registerAdapter(['mock'], adapter) + const handle = await ctx.agents.create({ + sessionId: SessionId('parent'), + agentOptions: { provider: 'mock', model: 'mock' }, + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'coding'), + }) + return { ctx, adapter, parent: handle.agent } +} + +/** The one-shot spawn request shape both in-process providers build. */ +function spawnRequest(parent: Agent) { + return { + label: 'child task', + prompt: [{ type: 'text' as const, text: 'child task' }], + parent, + signal: new AbortController().signal, + descriptor: snapshotSubagentDescriptor({ + mode: 'one-shot' as const, + provider: 'spawn', + label: 'child task', + }), + } +} + +describe('a child agent composed in-process', () => { + it('reaches the model with its parent\'s preset tools', async () => { + const { ctx, adapter, parent } = await setupPresetHost() + + const run = await startInProcessRun(spawnRequest(parent), {}) + await run.result + + const childRequest = adapter.requests.at(-1) + expect(childRequest?.tools?.map(tool => tool.name)).toEqual(['preset_only']) + expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['preset_only']) + await run.dispose() + }) + + it('carries its parent\'s prompt sections', async () => { + const { parent } = await setupPresetHost() + + const run = await startInProcessRun(spawnRequest(parent), {}) + await run.result + + expect(run.localAgent?.session.events.some(event => + event.type === 'request/header' + && JSON.stringify(event.data).includes('section for preset_only'))).toBe(true) + await run.dispose() + }) + + it('records the composition it ran under on the child header', async () => { + const { parent } = await setupPresetHost() + + const run = await startInProcessRun(spawnRequest(parent), {}) + await run.result + + // Without this the child's own history reads back under the deployment + // default, which is a different tool set than the one it actually used. + expect(run.localAgent?.session.header.agentPreset).toBe('coding') + await run.dispose() + }) + + it('follows a parent that switched preset while blank', async () => { + const { ctx, parent } = await setupPresetHost() + await ctx.agentPresets.recompose(parent.ctx, 'coding') + + const run = await startInProcessRun(spawnRequest(parent), {}) + await run.result + + expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['preset_only']) + await run.dispose() + }) +}) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 1241aa8042..495949dc0c 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: b69428e4af7d1f53adb22be1e59beb79c054713f +README.zh.md: 9f5eb5f1c508135c21bf3923f60f4f872de8e9f6 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 762030629c..b69428e4af 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -40,6 +40,10 @@ Start-time features are advertised in `provider.capabilities` because the servic - `toolFilter` — apply the requested child tool restriction. - `persona` — apply a per-child persona. +Every in-process child is composed by one call, `applyChildComposition(childCtx, parent, composition)`, which joins the parent's agent-preset composition before applying the child's own persona and tool filter. The join is what gives the child its capabilities: with every model-facing row on the agent plane, a child that joined nothing would reach the model with an empty tool registry ([`dsh-agent-presets`](../../preset/agent-presets/README.md)). Taking the parent as a parameter is deliberate — it makes composing a child WITHOUT that join unrepresentable at the call sites, which is the defect the one call exists to prevent. A deployment composing no preset roster joins nothing and needs nothing: its model-facing rows sit in the host composition, where the child already resolves them through the tool registry's global layer. + +`childSessionMeta()` records the joined preset id on the child's durable header for the same reason a top-level session records its own: the preset decides the tool schemas and prompt sections the model saw, so a cold read of the child's history has to rebuild that composition rather than the deployment default. It is read from the parent's live scope chain, not from the parent header, because a parent that switched preset while blank runs on the newer composition while its header still names the older one. + Continuable creation is the optional `SubagentProvider.prepareContinuable?()` method: its presence is the capability check, so the service rejects a configured continuable start on a provider without it, while a provider that has it may still serve ordinary one-shot delegations. The method returns only a detached `ContinuableCreateSpec` (`{ seed? }`) — data, never a capability: it carries no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation. A one-shot `SubagentRun` represents one disposable foreground delegation with one result and no cold-resume operation. ## The durable descriptor diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 535cc25895..9f5eb5f1c5 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -40,6 +40,10 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 - `toolFilter`:应用请求的子 agent 工具限制; - `persona`:应用每个子 agent 独立的 persona。 +每个进程内子 agent 都由一次调用完成组装:`applyChildComposition(childCtx, parent, composition)` 先加入父方的 agent-preset 组装,再应用该子 agent 自己的 persona 与工具限制。加入组装正是子 agent 获得能力的途径:所有面向模型的行都在 agent 平面,没有加入任何组装的子 agent 抵达模型时工具注册表是空的(见 [`dsh-agent-presets`](../../preset/agent-presets/README.md))。把父方作为参数是刻意的——这让"组装一个子 agent 却不做该加入"在各调用点无法表达,而这正是这一次调用所要杜绝的缺陷。未组装 preset roster 的部署不加入任何组装、也不需要加入:它的面向模型的行位于宿主组装中,子 agent 已经能通过工具注册表的全局层解析到它们。 + +`childSessionMeta()` 把所加入的 preset id 记在子 agent 的持久化 header 上,理由与顶层会话记录自己的那一个相同:preset 决定了模型所见的工具 schema 与提示段,因此冷读子 agent 的历史时必须重建那份组装,而不是部署默认值。该值从父方**活着的** scope 链读取,而不是从父方 header 读取,因为在空白期切换过 preset 的父方运行在更新的那份组装上,而它的 header 仍写着旧的那个。 + 可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec`(`{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。 ## 持久化描述符 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index ba1dd0fbc4..35504c9dc4 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -34,6 +34,7 @@ }, "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", @@ -47,6 +48,9 @@ "cordis": "^4.0.0-rc.7" }, "peerDependenciesMeta": { + "@deepseek-ai/dsh-agent-presets": { + "optional": true + }, "@deepseek-ai/dsh-session-persistence": { "optional": true }, @@ -62,6 +66,7 @@ }, "devDependencies": { "@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:^", diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index c477954468..c501a19a56 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -12,6 +12,12 @@ import type { Context } from 'cordis' import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' +// Type-only: make `ctx.get('agentPresets')` resolve to the preset roster when +// composed — a child inherits its parent's composition opportunistically (the +// documented `ctx.get` pattern), never as a hard dep. A rosterless deployment +// keeps its model-facing rows on the host plane, where the child already sees +// them through the tool registry's global layer. +import type {} from '@deepseek-ai/dsh-agent-presets' import { delegationDepthOf } from './depth.ts' /** Thrown when starting a child would exceed the requested depth cap. */ @@ -72,8 +78,15 @@ export function resolveChildAgentOptions( /** * Build the child session's durable creation metadata: the parent's workspace, * its direct lineage, coarse product origin, the recursion budget that must - * survive persistence, and the seed boundary that separates inherited parent - * history from child work. + * survive persistence, the seed boundary that separates inherited parent + * history from child work, and the composition the child runs under. + * + * The preset is read from the parent's LIVE scope chain rather than from its + * header, because a parent that switched preset while blank runs on the newer + * composition and its header still names the older one. Recording it is what + * makes a child's history reconstructable: without it a cold read of the child + * resolves the deployment default and rebuilds turns under a tool set the + * child never had. * @param parent - the delegating parent agent. * @param childDepth - the resolved delegation depth to persist. * @param lineageSeedLength - how many leading events came from the parent's log. @@ -85,8 +98,10 @@ export function childSessionMeta( lineageSeedLength: number, ): NonNullable<CreateAgentOptions['meta']> { const parentHeader = parent.session.header + const agentPreset = parent.ctx.get('agentPresets')?.composedPreset(parent.ctx) return { ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, + ...agentPreset === undefined ? {} : { agentPreset }, parentSession: parentHeader.id, // Navigation classification only; the descriptor remains the authority // for mode and continuation capability. @@ -106,13 +121,31 @@ export interface ChildComposition { } /** - * Apply one child's scoped composition inside its creation window: a shadowing - * persona section and a tool restriction, both owned by the child's scope and - * therefore invisible to its parent and siblings. + * Compose one child inside its creation window: join its parent's preset, then + * apply the child's own shadowing persona section and tool restriction, both + * owned by the child's scope and therefore invisible to its parent and + * siblings. + * + * The join comes first and the child's own registrations second, which is the + * order the layering already implies — the nearest scope wins a name, and a + * per-child restriction intersects with everything its chain admits — but + * stating it here keeps the two steps from being read as independent. + * + * Both steps live in ONE call because a child composed with only the second is + * exactly the defect this function exists to prevent: with every model-facing + * row on the agent plane, a child that joins no preset sees an empty tool + * registry and none of its parent's prompt sections. Taking the parent as a + * parameter is what makes that omission unrepresentable at the call sites. * @param childCtx - the child agent's scoped creation context. - * @param composition - the persona and tool filter to install. + * @param parent - the delegating parent whose composition the child joins. + * @param composition - the per-child persona and tool filter to install. */ -export function applyChildComposition(childCtx: Context, composition: ChildComposition): void { +export function applyChildComposition( + childCtx: Context, + parent: Agent, + composition: ChildComposition, +): void { + childCtx.get('agentPresets')?.composeFrom(childCtx, parent.ctx) if (composition.persona !== undefined) { childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona }) } diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 3425a12851..1cb16daca6 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -885,7 +885,7 @@ export class SubagentContinuationManager { // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() const setup = (childCtx: Context): AgentSetupCommit => { - applyChildComposition(childCtx, inputs.composition) + applyChildComposition(childCtx, parent, inputs.composition) return this.setupRegistry.apply(childCtx) } const observer = this.host.observeActivation(provider, childId, parent) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index c72f2ef68d..afc6138bd9 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/scope" }, + { + "path": "../../preset/agent-presets" + }, { "path": "../../session/session-persistence" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2507f83973..a814f2a7d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,6 +309,9 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../packages/settings/settings + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../packages/subagent/subagent '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../packages/core/system-prompt @@ -6285,6 +6288,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -6539,6 +6545,12 @@ importers: packages/subagent/subagent-inprocess: devDependencies: + '@cordisjs/plugin-include': + specifier: ^1.0.4 + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.5 + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6548,6 +6560,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-fs-sandbox': specifier: workspace:^ version: link:../../fs/fs-sandbox From c5dd99124cb0caee32ddd3f1856860ecb4acd149 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:26:47 +0800 Subject: [PATCH 099/229] docs: make routine translation lightweight --- ...-bilingual-docs-and-pairing-gate.i18n.yaml | 4 +-- ...6-07-02-bilingual-docs-and-pairing-gate.md | 4 +-- ...7-02-bilingual-docs-and-pairing-gate.zh.md | 4 +-- ...efed-minimal-translation-updates.i18n.yaml | 4 +-- ...-26-briefed-minimal-translation-updates.md | 6 ++-- ...-briefed-minimal-translation-updates.zh.md | 6 ++-- ...outine-documentation-translation.i18n.yaml | 6 ++++ ...eight-routine-documentation-translation.md | 30 +++++++++++++++++++ ...ht-routine-documentation-translation.zh.md | 30 +++++++++++++++++++ .agents/skills/dsh-code-review/SKILL.md | 2 +- .agents/skills/dsh-doc-site-sync/SKILL.md | 2 +- .agents/skills/dsh-doc-standards/SKILL.md | 2 +- .agents/skills/dsh-prose-standard/SKILL.md | 2 +- .agents/skills/dsh-translate-docs/SKILL.md | 8 ++++- .../dsh-translate-docs/agents/openai.yaml | 7 +++++ AGENTS.md | 2 +- docs/AGENTS.md | 2 +- docs/i18n/README.i18n.yaml | 4 +-- docs/i18n/README.md | 8 ++--- docs/i18n/README.zh.md | 8 ++--- docs/i18n/translation-rules.i18n.yaml | 4 +-- docs/i18n/translation-rules.md | 7 ++--- docs/i18n/translation-rules.zh.md | 7 ++--- 23 files changed, 118 insertions(+), 41 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md create mode 100644 .agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md create mode 100644 .agents/skills/dsh-translate-docs/agents/openai.yaml diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index a869a98b51..a993fdec64 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md -2026-07-02-bilingual-docs-and-pairing-gate.md: 9e6611aa8391e1478603bedb7d2b96fdd9a8bad2 -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 654d265f3e8b396e23a49fd53e522826a7adb2ff +2026-07-02-bilingual-docs-and-pairing-gate.md: d516c422d09a51cc47440d3ca73d914e96db2320 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 8d478d293b4d6a07e68da5036301816bdeb1bdfd diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index 9e6611aa83..d516c422d0 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -15,7 +15,7 @@ This repo's documentation corpus is read by people and agents inside and outside - **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, the Chinese side and every authored English source carry their switchers while listed generated English sources are exempt, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch. - **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it. - **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration. -- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent. +- **Translation is agent work with human review.** Routine changes use the direct one-pass path owned by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). The [extended translation skill](../../../skills/dsh-translate-docs/SKILL.md) retains delegated translation and the other heavier mechanisms for explicit user invocation; both paths defer to the documentation contracts as their sources of truth. ## Verification @@ -32,7 +32,7 @@ The verification contract covers each boundary independently. `verify-translatio ## Industry precedent -Paired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus a committed agent skill in place of a bot service. +Paired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus an agent-run workflow in place of a bot service. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index 654d265f3e..8d478d293b 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -15,7 +15,7 @@ Status: implemented - **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、中文侧和所有普通撰写的英文源都带切换行而清单内的生成英文源除外、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。 - **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。 - **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。 -- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。 +- **翻译是 agent 的工作,由人评审。** 常规改动采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.md)确立的直接单遍路径。[扩展翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)保留委派翻译和其他较重机制,供用户显式调用;两条路径均以文档契约为真源。 ## 验证 @@ -32,7 +32,7 @@ Status: implemented ## 业界先例 -带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个仓库内置的 agent skill 替代 bot 服务。 +带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个由 agent 运行的工作流替代 bot 服务。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml index 7f4bc40463..e98385c805 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md -2026-07-26-briefed-minimal-translation-updates.md: afd990b7b63adfd0e66a4726975b678d044e7cad -2026-07-26-briefed-minimal-translation-updates.zh.md: a6559667f0f1140d8a26cd5ebc4bb64b7b95fe45 +2026-07-26-briefed-minimal-translation-updates.md: 1c032fa07167ec2407d0f46707f942ba0c830494 +2026-07-26-briefed-minimal-translation-updates.zh.md: 3e4b235ea6d65e5b8fec60d24208924537577951 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md index afd990b7b6..1c032fa071 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md @@ -10,10 +10,10 @@ The [bilingual pairing contract](2026-07-02-bilingual-docs-and-pairing-gate.md) ## Decision -Pair updates run on a generated briefing instead of the guidance corpus; only new pairs still run the whole-document workflow, which is unchanged. +The extended manual workflow runs pair updates on a generated briefing instead of the guidance corpus; new pairs in that workflow still use the unchanged whole-document path. Routine agent work uses the direct path defined by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). - **`pnpm run gen-translation-brief [--apply] [pair...]`** ([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts), assembly in [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts)) prints, per out-of-sync pair, the authored side's diff from its recorded last-confirmed blob to the working tree plus the change mapped at the narrowest safely aligned granularity, deterministically widening on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (`--apply` splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units (headings, paragraphs, table rows, list items, code fences, block quotes, HTML blocks, thematic breaks, link definitions — matched by container-scoped kind sequences) each carry their last-confirmed source, current source, and current counterpart text with line numbers; units that do not align fall back to depth-matched heading sections; and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping instead of guessing. Terminology rows are matched against the changed spans only (word-boundary English matching with plural inflections), and for Chinese targets the briefing tracks each relevant term's document-wide first occurrence — when an edit moves it, the vacated and receiving spans join the briefing with an explanatory note, since the 首次出现 annotation must move with it. The unit mapping, code splice, and first-occurrence mechanics adopt the planner design from the incremental prompt-pipeline work; its provider-backed bake-off independently validated the same scope ladder for the automated pipeline. The briefing is the translator's whole working set; the full sources of truth remain the escalation path for decisions the briefing cannot answer. -- **The update path in [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md)** consumes the briefing: mechanical (code-fence-only) changes are applied with `--apply`, no subagent; prose diffs go to a subagent whose prompt is the briefing, not the corpus; verification is clause-by-clause on the changed spans, not the whole document. +- **When explicitly invoked, the update path in [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md)** consumes the briefing: mechanical (code-fence-only) changes are applied with `--apply`, no subagent; prose diffs go to a subagent whose prompt is the briefing, not the corpus; verification is clause-by-clause on the changed spans, not the whole document. - **The pairing gate takes pair arguments.** `verify-translation-pairing [pair...]` checks just the named pairs (any of a pair's three files, or the bare stem, names it); the corpus-wide sweep remains the no-argument form that `doc-sync` and CI run. `--write` now requires naming the confirmed pairs — bare `--write` refuses, and re-recording everything is an explicit `--write --all` — because the old bare form silently blessed every drifted pair in the tree, including ones the caller never looked at, and a prose-only drift would then stay green forever. Each record's comment names its own scoped command. Before recording, `--write` stores each side's exact bytes with `git hash-object -w --stdin` and pins the blob under a content-addressed local `refs/dsh/translation-pairing/snapshots/` ref; an uncommitted last-confirmed snapshot is therefore available to the briefing generator's later `git cat-file`, not merely named by a hash that Git cannot resolve or left vulnerable to garbage collection. ## Benchmark @@ -38,7 +38,7 @@ A second head-to-head replay on the same ten examples compared this note's shipp ## Consequences -- A small prose edit's counterpart update now costs a briefing generation plus one small focused task — no corpus reads, no archaeology, no corpus-wide scans inside the loop — and the same PR obligation holds; the cheap path and the correct path point the same way. +- In the explicitly invoked extended workflow, a small prose edit's counterpart update costs a briefing generation plus one small focused task — no corpus reads, no archaeology, no corpus-wide scans inside the loop — and the same PR obligation holds. - The briefing generator is a second consumer of the consistency records: recorded blob hashes now also drive diff recovery and section mapping, strengthening the incentive to keep records honest. - Each distinct confirmed snapshot retains a content-addressed local ref and object. An abandoned re-record may therefore leave an extra durable pin, but it changes no branch or commit history; this local retention is the tradeoff that prevents garbage collection from invalidating an accepted pairing record. - `--write` without arguments no longer works; muscle-memory callers must name pairs or pass `--all`. That is the point — the bulk bless is now a visible, deliberate act. diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md index a6559667f0..3e4b235ea6 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md @@ -10,10 +10,10 @@ Status: implemented ## 决策 -配对更新基于生成的简报(briefing)运行,而非基于指导语料;只有新建配对仍走整篇文档工作流,后者保持不变。 +扩展的手动工作流使用生成的简报(briefing)而非指导语料来更新配对;该工作流中的新配对仍采用保持不变的整篇文档路径。常规 agent 工作采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.md)定义的直接路径。 - **`pnpm run gen-translation-brief [--apply] [pair...]`**([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts),组装逻辑在 [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts))针对每个失去同步的配对,打印被改一侧从其记录在案的上次确认 blob 到当前工作区的 diff,并附上以能安全对齐的最窄粒度映射的这次改动,映射失败时粒度确定性地逐级放宽:仅落在配对中逐字节一致的围栏代码块内的改动会直接算出(`--apply` 会把它拼接进对侧文件,并在写入前用配对门禁的结构签名校验所得结果);否则,每个有改动的 Markdown 单元(标题、段落、表格行、列表项、围栏代码块、块引用、HTML 块、分隔线、链接定义;匹配依据是以容器为作用域的种类序列)都带上各自的上次确认源文、当前源文与当前对侧文本及行号;无法对齐的单元回退到按深度匹配的标题章节;当章节也无法对齐或两侧同时漂移时,简报会明说这一点并省略映射,而不是靠猜。术语表行只与改动块匹配(英文术语按词边界匹配,含复数变形);当目标侧是中文时,简报还会跟踪每个相关术语在整篇文档中的首次出现:一旦某次编辑使其移位,腾出的与接收的两处区间就会附一条解释性说明加入简报,因为「首次出现」括注必须随之移动。单元映射、代码拼接与首次出现机制采纳了增量提示词流水线工作的规划器设计;该项工作中接入提供方的对比评测,已为自动流水线独立验证了同一套范围阶梯。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。 -- **[dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 中的更新路径**消费这份简报:机械类改动(只涉及围栏代码块)用 `--apply` 应用,不动用 subagent;行文类 diff 交给 subagent,其提示词就是简报本身,而非指导语料;核验只对改动块逐句进行,不覆盖整篇文档。 +- **显式调用时,[dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 中的更新路径**消费这份简报:机械类改动(只涉及围栏代码块)用 `--apply` 应用,不动用 subagent;行文类 diff 交给 subagent,其提示词就是简报本身,而非指导语料;核验只对改动块逐句进行,不覆盖整篇文档。 - **配对门禁接受配对参数。**`verify-translation-pairing [pair...]` 只检查被点名的配对(配对三个文件中的任意一个,或其裸词干,都能指代该配对);全语料扫描仍是 `doc-sync`(文档同步门禁)与 CI 运行的无参数形式。`--write` 现在要求点名已确认的配对:裸 `--write` 会拒绝执行,重新记录全部配对必须显式写 `--write --all`;原因是旧的裸形式会默默为树中每一个漂移的配对背书,包括调用者从未看过的那些,纯行文层面的漂移于是可以永远保持绿灯。每份记录的注释都写明针对该配对自身的按对命令。写下记录之前,`--write` 用 `git hash-object -w --stdin` 存入每一侧的精确字节,并在内容寻址的本地 `refs/dsh/translation-pairing/snapshots/` ref 下固定该 blob;未提交的上次确认快照因此能被简报生成器之后的 `git cat-file` 取回,而不只是留下一个 Git 无法解析的 hash 名称或暴露于垃圾回收。 ## 基准测试 @@ -38,7 +38,7 @@ Status: implemented ## 后果 -- 一次小的行文修改,其对侧更新如今只需生成一份简报,外加一个小而聚焦的任务(不读指导语料、不翻查历史、循环内不做全语料扫描),同一 PR 内完成更新的义务保持不变;低成本的路径与正确的路径指向同一个方向。 +- 在显式调用的扩展工作流中,一次小的行文修改,其对侧文件更新只需生成一份简报,外加一个小而聚焦的任务(不读指导语料、不翻查历史、循环内不做全语料扫描),同一 PR 内完成更新的义务保持不变。 - 简报生成器成为一致性记录的第二个消费方:记录的 blob hash 如今还驱动 diff 还原与章节映射,这进一步强化了如实维护记录的动机。 - 每个不同的已确认快照都会保留一个内容寻址的本地 ref 和对象。因此,中途放弃的重新记录可能留下额外的持久固定项,但它不会改变任何分支或提交历史;这种本地保留正是防止垃圾回收让已接受配对记录失效所付出的代价。 - 不带参数的 `--write` 不再可用;靠肌肉记忆的调用者必须点名配对或传 `--all`。这正是目的所在:批量背书如今是一个可见的、有意为之的动作。 diff --git a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml new file mode 100644 index 0000000000..d86489d45a --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.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-08-lightweight-routine-documentation-translation.md +2026-08-08-lightweight-routine-documentation-translation.md: 713c2f14541aff49411b6f7d8b6bf5b4e02fa667 +2026-08-08-lightweight-routine-documentation-translation.zh.md: 7cb13e9fcaa8b8a4d38ab6c0050eec99c1025b46 diff --git a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md new file mode 100644 index 0000000000..713c2f1454 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md @@ -0,0 +1,30 @@ +# Agent Note: Lightweight routine documentation translation + +Status: implemented + +English | [中文](2026-08-08-lightweight-routine-documentation-translation.zh.md) + +## Problem + +Routine bilingual edits automatically selected the full [translation skill](../../../skills/dsh-translate-docs/SKILL.md). Even after the [briefed-update optimization](2026-07-26-briefed-minimal-translation-updates.md), a small documentation change could still load a specialized workflow, generate a briefing, delegate prose to a subagent, and perform a separate verification pass. That orchestration consumed more time, context, and model tokens than translating the changed text itself, and automatic skill discovery exposed the workflow on ordinary documentation turns. + +## Decision + +- **Routine translation is one shot and one pass.** The active agent loads [terminology.md](../../../../docs/i18n/terminology.md), translates only the changed content directly, preserves reviewed counterpart prose outside the change, and re-records the pair. It does not invoke a translation skill, generate a briefing, start a separate translation-review pass, or delegate translation to a subagent. +- **The extended workflow is manual-only.** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) retains its briefing, delegated prose, whole-document, and scoped-verification paths. Claude Code sees `disable-model-invocation: true` with `user-invocable: true` in `SKILL.md`; Codex sees `policy.allow_implicit_invocation: false` in `agents/openai.yaml`. The repository's `.claude/skills` symlink projects the same skill directory to Claude Code, so both products share one committed workflow while enforcing their own invocation metadata. +- **Automatic workflows do not chain into the manual skill.** Root and documentation instructions own the lightweight default. Documentation, website-sync, prose, and code-review skills link to those instructions or the i18n contracts instead of loading `dsh-translate-docs` from an inferred bilingual change. +- **The pairing and review contracts stay intact.** Both language files still update together, untouched counterpart wording remains stable, terminology stays binding, the consistency record is rewritten only after the active agent confirms the pair, and `doc-sync` retains the corpus-wide mechanical checks. Human review still owns semantic translation quality. + +## Alternatives considered + +- **Delete the extended skill and briefing tools** — rejected: explicit manual use remains valuable for whole-document translations, difficult reconciliation, and callers that deliberately choose the guarded workflow. +- **Replace the extended skill with an automatically invoked lightweight skill** — rejected: another automatic skill would still add discovery context and an invocation boundary around a task the active agent can complete directly from the terminology table and standing instructions. +- **Keep automatic invocation only for new pairs or large changes** — rejected: size-based inference is another hidden policy and can unexpectedly activate the expensive workflow. The user, not the agent, chooses when the extended path is worth its cost. +- **Drop the terminology load as well** — rejected: the glossary is the small, binding input that prevents repository-wide term drift; removing it would trade token savings for inconsistent product language. + +## Consequences + +- Ordinary development pays for the changed source text, its local counterpart context, and the terminology table rather than the extended workflow's briefing and subagent context. +- The active agent owns the final routine translation in the same turn. The lightweight path deliberately gives up the extended workflow's generated alignment, delegated isolation, and separate prose-verification pass. +- Explicit users can still invoke the full workflow through `/dsh-translate-docs` in Claude Code or `$dsh-translate-docs` in Codex. +- The Claude Code frontmatter and Codex policy file are separate product contracts and must remain aligned when the skill's invocation policy changes. diff --git a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md new file mode 100644 index 0000000000..7cb13e9fca --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 轻量化日常文档翻译 + +Status: implemented + +[English](2026-08-08-lightweight-routine-documentation-translation.md) | 中文 + +## 问题 + +日常双语编辑会自动选用完整的[翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)。即使经过[基于简报的更新优化](2026-07-26-briefed-minimal-translation-updates.md),一次小的文档改动仍可能加载专用工作流、生成简报、把行文翻译委派给 subagent,并另行执行一轮核验。这种编排耗费的时间、上下文和模型 token 比直接翻译改动文本本身还多,而且 skill 的自动发现机制还会在普通文档处理轮次中暴露该工作流。 + +## 决策 + +- **日常翻译一次完成,只处理一遍。** 当前 agent(智能体)加载 [terminology.md](../../../../docs/i18n/terminology.md),直接翻译发生改动的内容,保留改动之外已经评审的对侧文件行文,并重新记录配对。它不会调用翻译 skill、生成简报、启动单独的翻译评审轮次,也不会把翻译委派给 subagent。 +- **扩展工作流仅限手动调用。** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 保留简报、行文翻译委派、整篇文档和按范围核验路径。在 `SKILL.md` 中,Claude Code 读取 `disable-model-invocation: true` 和 `user-invocable: true`;在 `agents/openai.yaml` 中,Codex 读取 `policy.allow_implicit_invocation: false`。仓库的 `.claude/skills` 符号链接把同一个 skill 目录映射给 Claude Code,因此两个产品共享同一份提交到仓库的工作流,同时分别执行各自的调用元数据契约。 +- **自动工作流不会串联调用这项仅限手动调用的 skill。** 轻量默认行为由根级指令和文档指令定义。文档、网站同步、行文和代码评审 skill 会链接这些指令或 i18n 契约,而不会因为推断到双语改动就加载 `dsh-translate-docs`。 +- **配对契约与评审契约保持不变。** 两种语言文件仍会一并更新;未触及的对侧文件措辞保持稳定;术语约束仍然有效;只有当前 agent 确认配对后,才会重写一致性记录;`doc-sync`(文档同步门禁)继续执行全语料机械检查。语义层面的翻译质量仍由人工评审负责。 + +## 曾考虑的替代方案 + +- **删除扩展 skill 和简报工具**:不予采纳。在整篇文档翻译或棘手的两侧内容协调中,以及对有意选择受控工作流的调用方而言,显式手动调用仍有价值。 +- **用自动调用的轻量 skill 取代扩展 skill**:不予采纳。另一项自动 skill 仍会给这项任务增加发现上下文和调用边界,而当前 agent 仅依据术语表与常驻指令即可直接完成该任务。 +- **仅对新配对或大规模改动保留自动调用**:不予采纳。基于规模的推断同样是一项隐藏政策,可能出乎意料地启用高开销工作流。何时值得为扩展路径付出成本,应由用户而非 agent 决定。 +- **同时取消加载术语表**:不予采纳。术语表是体量小但有约束力的输入,可以防止整个仓库发生术语漂移;移除它等于用产品语言不一致换取 token 节省。 + +## 后果 + +- 普通开发的成本来自发生改动的源文本、其局部对侧文件上下文和术语表,不再来自扩展工作流的简报与 subagent 上下文。 +- 当前 agent 在同一轮次内对日常翻译的最终结果负责。轻量路径有意放弃扩展工作流提供的自动生成对齐信息、委派所提供的隔离,以及单独的行文核验轮次。 +- 用户仍可在 Claude Code 中通过 `/dsh-translate-docs`,或在 Codex 中通过 `$dsh-translate-docs` 显式调用完整工作流。 +- Claude Code frontmatter 与 Codex 策略文件是彼此独立的产品契约;skill 调用策略变更时,两者必须保持一致。 diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 572c36a8a1..e79f11cdc7 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -15,7 +15,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — - [dsh-prose-standard](../dsh-prose-standard/SKILL.md): required coverage and editorial judgment for comments, docs, prompts, and visible strings. - [docs/testing.md](../../../docs/testing.md) and the [quality-gates Agent Note](../../notes/implemented/process/2026-06-11-quality-gates.md): required test tiers and gates. - [Agent Notes](../../notes/README.md): design rationale. Treat disagreement with an Agent Note as a design discussion, not an automatic veto. -- For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md), [terminology.md](../../../docs/i18n/terminology.md), and [dsh-translate-docs](../dsh-translate-docs/SKILL.md). +- For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md) and [terminology.md](../../../docs/i18n/terminology.md); the extended translation skill is outside automatic review and runs only on explicit user invocation. ## Blocking requirements diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md index 5d118f2257..5d39470d72 100644 --- a/.agents/skills/dsh-doc-site-sync/SKILL.md +++ b/.agents/skills/dsh-doc-site-sync/SKILL.md @@ -12,7 +12,7 @@ Repository translations follow the sibling pairing contract: English `foo.md`, C ## Read the owning contracts - Read [docs/AGENTS.md](../../../docs/AGENTS.md) and use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) when deciding where content belongs or changing product documentation prose. -- Use [dsh-translate-docs](../dsh-translate-docs/SKILL.md) whenever an edited source has a bilingual counterpart. +- For an edited bilingual source, follow the lightweight routine path in [docs/AGENTS.md](../../../docs/AGENTS.md#writing-rules) and the [pairing contract](../../../docs/i18n/README.md); never invoke the extended translation skill automatically. - Read the current `DocsPage` type and entries in [website/docs.ts](../../../website/docs.ts) before changing the manifest; do not rely on a remembered field set. - Read [website/.vitepress/config.ts](../../../website/.vitepress/config.ts) before adding a new section, sidebar collection, locale, or top-level navigation item. diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index a42c27cfda..1ea836b8ee 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -53,4 +53,4 @@ Apply the ordered relocate-condense-raise policy in [docs/AGENTS.md](../../../do ## Validation and PR hygiene -Run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; JSDoc changes may regenerate catalogs. If a paired doc changed, follow [dsh-translate-docs](../dsh-translate-docs/SKILL.md) and run `pnpm run verify-translation-pairing --write <pair>`. The PR body should give word deltas, explain any deliberately long exception, and list checks. +Run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; JSDoc changes may regenerate catalogs. If a paired doc changed, follow the [lightweight routine path](../../../docs/AGENTS.md#writing-rules) and run `pnpm run verify-translation-pairing --write <pair>`. The PR body should give word deltas, explain any deliberately long exception, and list checks. diff --git a/.agents/skills/dsh-prose-standard/SKILL.md b/.agents/skills/dsh-prose-standard/SKILL.md index 6ad226baf4..f100f78613 100644 --- a/.agents/skills/dsh-prose-standard/SKILL.md +++ b/.agents/skills/dsh-prose-standard/SKILL.md @@ -23,7 +23,7 @@ Always exclude `vendor/` from discovery, review, and edits, even when the reques Also exclude `.agents/notes/archived/` from prose review and edits. Archived Agent Notes are frozen snapshots; inspect an exact target only to understand a historical inbound citation, never to modernize its prose or outbound links. -Treat generated catalogs, snapshots, and fixtures as derivative. Edit the owning source or scenario first, then regenerate the artifact. When a generator extracts a summary from owner prose, make the extracted sentence complete for that surface. Bilingual pairs have no permanent owner: either language may be the authored side for an update. Update the counterpart minimally and re-record the pair. +Treat generated catalogs, snapshots, and fixtures as derivative. Edit the owning source or scenario first, then regenerate the artifact. When a generator extracts a summary from owner prose, make the extracted sentence complete for that surface. Bilingual pairs have no permanent owner: either language may be the authored side for an update. Follow the [lightweight routine path](../../../docs/AGENTS.md#writing-rules), update the counterpart minimally, and re-record the pair. ## Preserve the complete proposition diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index 586eb554c8..5057d8b760 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -1,10 +1,16 @@ --- name: dsh-translate-docs -description: Use when creating or updating the bilingual counterpart of a doc in this repo (English ↔ Chinese pairs) — tells the orchestrating agent when to delegate translation to a subagent, and orients the translator to the pairing contract, the terminology source of truth, the translation rules, and the consistency gate that verifies the result +description: Manually run the extended DeepSeek Harness bilingual-document workflow, including generated briefings, delegated prose translation, whole-document translation, and scoped pairing verification. +disable-model-invocation: true +user-invocable: true --- # Translating DeepSeek-Harness docs +## Invocation boundary + +Run this extended workflow only when the user explicitly invokes `dsh-translate-docs` by name. Never select or load it for ordinary documentation work, from another skill, or from an inferred translation need; routine translation follows the one-shot, one-pass rule in [docs/AGENTS.md](../../../docs/AGENTS.md). + ## What this skill is **This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. diff --git a/.agents/skills/dsh-translate-docs/agents/openai.yaml b/.agents/skills/dsh-translate-docs/agents/openai.yaml new file mode 100644 index 0000000000..8f02948105 --- /dev/null +++ b/.agents/skills/dsh-translate-docs/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "DSH Extended Doc Translation" + short_description: "Run the full bilingual documentation workflow manually" + default_prompt: "Use $dsh-translate-docs to run the extended bilingual-document workflow for the specified pair." + +policy: + allow_implicit_invocation: false diff --git a/AGENTS.md b/AGENTS.md index a8a202147c..9daf45a832 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,7 +136,7 @@ Everything compiles under `strict: true` with `noImplicitAny`; every remaining ` Comments and docs state complete contracts and context, not reasoning transcripts. Use direct, concrete terms. Do not use metaphors. Before writing `contract`, `boundary`, or `shape`, ask whether a more exact term names the subject: write `response fields`, `JSON validation`, or `ESM exports` instead of `response shape`, `validation boundary`, or `module shape`. Keep `contract` for preconditions, postconditions, invariants, compatibility promises, and other obligations that callers, callees, implementers, providers, producers, or consumers rely on. Keep a literal process, wire, security, transaction, or lifecycle boundary. Do not narrate control flow or tests, preserve review history, or restate code. Keep behavior, failure, timing, ownership, and safe-use facts; link the rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for decisions. Wire mechanically checkable invariants into an executed top-level gate and prove each changed acceptance path rejects an invalid case. Use narrow, justified exceptions instead of disabling a rule globally. -Docs accompany every code change: update affected README and JSDoc contracts together; update both sides of a bilingual pair and re-record it ([i18n contract](docs/i18n/README.md)). Current-state prose, one physical line per paragraph, one home per fact, and word budgets live in [docs/AGENTS.md](docs/AGENTS.md). +Docs accompany every code change: update affected README and JSDoc contracts together. Routine bilingual work follows [docs/AGENTS.md](docs/AGENTS.md); only explicit user invocation may run `dsh-translate-docs`. Current-state prose, one physical line per paragraph, one home per fact, and word budgets live there. ## Editing these instructions diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 64ea632cb6..bc2081da1d 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -40,7 +40,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The owning [subsystems page](subsystems/README.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types; a type is documented on its declaring package group's page ([page scoping](../.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md)). -- **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). +- **Bilingual pairs update together**: load [terminology](i18n/terminology.md), translate changed content one-shot and one-pass in the active agent, preserve untouched counterpart prose, and re-record. Only explicit user invocation may run `dsh-translate-docs` ([contract](i18n/README.md)). - **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, failure, timing, ownership, modality, exceptions, consequences, and non-obvious orientation; delete narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link its rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for details. - Write directly: name actors and facts ([decision](../.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)). Reserve `seam` for the defined capability. Name the exact check, type, API, operation, or behavior instead of metaphorical "gate", "vocabulary", or "surface". diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 45e4077203..087e9e9dfe 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/i18n/README.md -README.md: af6a35294bc23adcd6214747f78a28a69ae443d1 -README.zh.md: 74cb98932460d014dab26d3b48cd81142e0f7bf8 +README.md: 9875eb0c9924daa0b519923e9aac8a67de8cda61 +README.zh.md: eed73226dffd9bc1f6af7b21af5b0b77363878e2 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index af6a35294b..9875eb0c99 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). +This repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation. ## The pairing contract @@ -15,7 +15,7 @@ This repo's documentation is read by people and agents both inside and outside t foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b ``` - Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief <pair>` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form). + Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief <pair>` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form). When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives. - **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output. @@ -35,7 +35,7 @@ Source-oriented code gates consume an exact `.zh.md` fence sequence as a derivat `pnpm run verify-translation-pairing <pair...>` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level. -The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write <pair>`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI. +The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write <pair>`**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI. The gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. @@ -57,4 +57,4 @@ Generated English references and graphs participate in pairing when a reviewed C ## Division of labor -Counterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`. +Routine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 74cb989324..eed73226df 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。 +本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。 ## 配对约定 @@ -15,7 +15,7 @@ foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b ``` - 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief <pair>` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。 + 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief <pair>` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。 - **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。 @@ -35,7 +35,7 @@ `pnpm run verify-translation-pairing <pair...>` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。 -这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write <pair>`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。 +这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write <pair>` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。 门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。 @@ -57,4 +57,4 @@ ## 分工 -这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。 +日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。 diff --git a/docs/i18n/translation-rules.i18n.yaml b/docs/i18n/translation-rules.i18n.yaml index e8e4d8f801..34b03c956f 100644 --- a/docs/i18n/translation-rules.i18n.yaml +++ b/docs/i18n/translation-rules.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/i18n/translation-rules.md -translation-rules.md: fb6aa9ac05bebe68ff9213af99f64457bdb1ad6f -translation-rules.zh.md: 04dd0a704e19502c676ea0966437870c5af0624f +translation-rules.md: ce20ed9a9673b0782ef07c9a4a21ff1c98ace960 +translation-rules.zh.md: daea57ab1d3a1abbad442982c8bb1c189478b8a8 diff --git a/docs/i18n/translation-rules.md b/docs/i18n/translation-rules.md index fb6aa9ac05..ce20ed9a96 100644 --- a/docs/i18n/translation-rules.md +++ b/docs/i18n/translation-rules.md @@ -2,7 +2,7 @@ English | [中文](translation-rules.zh.md) -How to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally; the committed agent workflow that applies them is [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary. +How to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally. Routine agent work translates the changed content directly in one terminology-guided pass; the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow runs only when the user explicitly invokes it. Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary. ## Faithfulness @@ -13,7 +13,7 @@ How to translate between the two sides of a documentation pair in this repo. Bot ## Voice - The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose. -- Write as a native technical author restating the content, not as a translator transposing sentences. Then verify against the source clause by clause: nothing added, nothing dropped — fluency never justifies losing a clause. +- Write as a native technical author restating the content, not as a translator transposing sentences, while preserving every source clause: nothing added, nothing dropped — fluency never justifies losing a clause. - Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人). - Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it. - Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs. @@ -54,8 +54,7 @@ These rules govern the Chinese side; the English side follows the repo's normal ## Quality bar - A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra. -- Before handing off, self-check the result against this file and re-read the counterpart ALONE, without the source side by side; awkward phrasing is easier to hear without the source anchoring you. -- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Manually verify list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone. +- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Human review owns list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone. ## References diff --git a/docs/i18n/translation-rules.zh.md b/docs/i18n/translation-rules.zh.md index 04dd0a704e..daea57ab1d 100644 --- a/docs/i18n/translation-rules.zh.md +++ b/docs/i18n/translation-rules.zh.md @@ -2,7 +2,7 @@ [English](translation-rules.md) | 中文 -本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效;应用这些规则的仓库内置 agent 工作流是 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。 +本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效。日常工作中,agent 会在术语指导下直接一次完成有改动内容的翻译;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时运行。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。 ## 忠实性 @@ -13,7 +13,7 @@ ## 行文 - 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。 -- 以母语技术作者的身份重述内容,而不是逐句转写的译者。写完后逐句对照原文核验:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。 +- 以母语技术作者的身份重述内容,而不是以译者身份逐句转写,同时保留原文的每个语义成分:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。 - 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。 - 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。 - 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。 @@ -54,8 +54,7 @@ ## 质量标准 - 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。 -- 交付前,请对照本文自查一遍,并**单独通读对侧文件**,不与源侧对照;不对照原文时,更容易察觉别扭的表达。 -- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体仍需人工核对。 +- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体则由人工评审负责。 ## 参考资料 From b4581c8b97465bb0155f69dd19d8eecb48f6982b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:44:45 +0800 Subject: [PATCH 100/229] test(snapshot): refresh translation prompt fixture --- .../request-response.expected.json | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 7f2c71353e..ab862dba34 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -24,27 +24,43 @@ }, { "role": "user", +<<<<<<< HEAD "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief <pair>` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing <pair...>` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write <pair>`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief <pair>` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing <pair...>` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write <pair>`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" - }, - { - "role": "user", - "content": "# Translation rules\n\nEnglish | [中文](translation-rules.zh.md)\n\nHow to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally; the committed agent workflow that applies them is [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary.\n\n## Faithfulness\n\n- The counterpart *MUST* say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change.\n- The counterpart *SHOULD* read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse.\n- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom.\n\n## Voice\n\n- The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose.\n- Write as a native technical author restating the content, not as a translator transposing sentences. Then verify against the source clause by clause: nothing added, nothing dropped — fluency never justifies losing a clause.\n- Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人).\n- Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it.\n- Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs.\n- When translating into Chinese, category nouns use Chinese with a first-mention English annotation (实操手册(cookbook)); when translating into English, use the conventional English category name. Literal directory or file references stay code-formatted English.\n\n## Structure preservation\n\nThe pairing gate checks heading depths, fenced code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. Preserve the rest of the frame manually; the paired files MUST match one to one in:\n\n- heading hierarchy (same levels, same order — heading TEXT is translated),\n- list shape and numbering,\n- tables (same columns, same row order; header cells translated per terminology),\n- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,\n- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,\n- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not.\n\nThe repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline.\n\n## Terminology\n\n- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; every listed term MUST follow its row and its \"不要译作\" prohibitions. A Chinese target uses the \"中文\" column and its \"首次出现\" annotation; an English target uses the \"English\" column without adding a Chinese gloss.\n- For a Chinese target, an unlisted technical term MAY use an established rendering from a major Chinese-language OSS or vendor source (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs), cited in the PR. Without such precedent it MUST stay in English and be listed under 「待定术语」(pending terms) with a suggested rendering.\n- For an English target, use the established English technical term. If the source term has no unambiguous established equivalent, preserve it with a short explanatory gloss and list it under pending terms. Neither direction may invent a rendering inline; a decided term enters [terminology.md](terminology.md) in the same PR or a follow-up.\n\n## Typography\n\nThese rules govern the Chinese side; the English side follows the repo's normal Markdown conventions (root `AGENTS.md`). The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011:\n\n- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything.\n- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`).\n- Chinese prose *SHOULD* prefer colons, periods, commas, or parentheses over em dashes. Keep an em dash only when no other punctuation preserves the sentence naturally.\n- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas.\n- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always.\n- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code.\n- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice).\n- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration.\n\n## Quality bar\n\n- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra.\n- Before handing off, self-check the result against this file and re-read the counterpart ALONE, without the source side by side; awkward phrasing is easier to hear without the source anchoring you.\n- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Manually verify list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.\n\n## References\n\nAuthorities cited by these rules, for humans and agents who want the underlying reasoning:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation.\n- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice.\n- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team.\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone.\n- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides.\n- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines.\n- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize.\n" +======= + "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, enforcement gate, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief <pair>` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing <pair...>` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write <pair>`**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files whose generators emit English only; a hand-written translation would go stale on regeneration.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", - "content": "# 翻译规则\n\n[English](translation-rules.md) | 中文\n\n本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效;应用这些规则的仓库内置 agent 工作流是 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。\n\n## 忠实性\n\n- 对侧文件*必须*传达与撰写侧相同的内容:不添加行为、前置条件、警告、版本声明或示例,也不漏掉任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜;请修正错误的一侧,并在同一个变更里同步更新另一侧。\n- 对侧文件读起来*应当*是其语言自然的技术文字,而非逐词对照的译文。请根据语义翻译,在目标语言语法需要时重组句子,并保持原作者的语域(比如:简练的保持简练)。\n- 不要翻译不可译的内容:如果一句话依赖源语言的习语、无法自然转换,请翻译它的意思,而非习语本身。\n\n## 行文\n\n- 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。\n- 以母语技术作者的身份重述内容,而不是逐句转写的译者。写完后逐句对照原文核验:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。\n- 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。\n- 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。\n- 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。\n- 翻译为中文时,类别名词使用中文并在首现括注英文(实操手册(cookbook));翻译为英文时,使用通行的英文类别名。指目录或文件本身时保留代码体英文。\n\n## 结构保持\n\n配对门禁会检查标题深度、围栏代码块、表格行列数、列表类型、有序列表起始编号、列表项数量与链接目标;门禁未覆盖的结构仍需人工核对。两个配对文件必须在以下方面一一对应:\n\n- 标题层级(相同级别、相同顺序;标题的**文字**要翻译);\n- 列表形态与编号;\n- 表格(相同的列、相同的行序;表头单元格按术语表翻译);\n- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;\n- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;\n- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。\n\n本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。\n\n## 术语\n\n- [terminology.md](terminology.md) 是双向的术语真源。翻译前请先加载它;表内术语必须遵守对应行与「不要译作」禁项。译成中文时,采用「中文」列,并按「首次出现」列括注;译成英文时,采用「English」列,不加中文括注。\n- 译成中文时,术语表未收录的技术术语只有在主流中文 OSS 文档或厂商资料中已有通行译法时才可以翻译(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档),并须在 PR 中注明出处;否则必须保留英文,并在 PR 描述的「待定术语」中给出建议译法。\n- 译成英文时,采用通行的英文技术术语。如果源术语没有明确的通行对应词,则保留原词、附上简短说明,并列入「待定术语」。两个方向都不得自行创造译法;确定后的术语须在同一个 PR 或后续 PR 中加入 [terminology.md](terminology.md)。\n\n## 排版\n\n本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。以下中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) 与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011:\n\n- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。\n- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。\n- 中文行文*应当*优先使用冒号、句号、逗号或括号,尽量不用破折号;只有其他标点都无法自然表达时才保留破折号。\n- 顿号:中文的并列项之间使用顿号(、),而非逗号。\n- 禁止使用全角数字或全角拉丁字母:永远不写 `123`,永远写 `123`。\n- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek。除非引用代码,否则绝不写 `github`/`Github`。\n- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。\n- 强调标记(`**加粗**`、`*斜体*`)落在与对侧相同的文字段上。中文没有斜体,渲染效果可能看不出差别,不要用引号或其他装饰替代。\n\n## 质量标准\n\n- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。\n- 交付前,请对照本文自查一遍,并**单独通读对侧文件**,不与源侧对照;不对照原文时,更容易察觉别扭的表达。\n- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体仍需人工核对。\n\n## 参考资料\n\n本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines):中西文混排空格与标点的社区事实标准。\n- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md):与本文同形态的仓库内置翻译规则文件;空格、标点与术语表实践。\n- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/):最大的中文本地化团队的术语首现与标点实践。\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5):逐术语的译/留决策与语气。\n- [zh-style-guide](https://zh-style-guide.readthedocs.io):社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。\n- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides):排版学与厂商本地化的正式基线。\n- GB/T 19682-2005《翻译服务译文质量要求》:国家标准;本文「忠实性」与「术语」两节将其三项基本要求(忠实原文、术语统一、行文通顺)落实为可操作的规则。\n" + "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对契约、强制门禁、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n## 配对契约\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief <pair>` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n- **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing <pair...>` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write <pair>` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分契约由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件,其生成器只输出英文;手写译文会在重新生成时变得陈旧。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" +>>>>>>> 45777c7624 (test(snapshot): refresh translation prompt fixture) }, { "role": "user", + "content": "# Translation rules\n\nEnglish | [中文](translation-rules.zh.md)\n\nHow to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally. Routine agent work translates the changed content directly in one terminology-guided pass; the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow runs only when the user explicitly invokes it. Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary.\n\n## Faithfulness\n\n- The counterpart *MUST* say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change.\n- The counterpart *SHOULD* read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse.\n- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom.\n\n## Voice\n\n- The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose.\n- Write as a native technical author restating the content, not as a translator transposing sentences, while preserving every source clause: nothing added, nothing dropped — fluency never justifies losing a clause.\n- Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人).\n- Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it.\n- Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs.\n- When translating into Chinese, category nouns use Chinese with a first-mention English annotation (实操手册(cookbook)); when translating into English, use the conventional English category name. Literal directory or file references stay code-formatted English.\n\n## Structure preservation\n\nThe pairing gate checks heading depths, fenced code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. Preserve the rest of the frame manually; the paired files MUST match one to one in:\n\n- heading hierarchy (same levels, same order — heading TEXT is translated),\n- list shape and numbering,\n- tables (same columns, same row order; header cells translated per terminology),\n- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,\n- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,\n- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not.\n\nThe repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline.\n\n## Terminology\n\n- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; every listed term MUST follow its row and its \"不要译作\" prohibitions. A Chinese target uses the \"中文\" column and its \"首次出现\" annotation; an English target uses the \"English\" column without adding a Chinese gloss.\n- For a Chinese target, an unlisted technical term MAY use an established rendering from a major Chinese-language OSS or vendor source (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs), cited in the PR. Without such precedent it MUST stay in English and be listed under 「待定术语」(pending terms) with a suggested rendering.\n- For an English target, use the established English technical term. If the source term has no unambiguous established equivalent, preserve it with a short explanatory gloss and list it under pending terms. Neither direction may invent a rendering inline; a decided term enters [terminology.md](terminology.md) in the same PR or a follow-up.\n\n## Typography\n\nThese rules govern the Chinese side; the English side follows the repo's normal Markdown conventions (root `AGENTS.md`). The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011:\n\n- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything.\n- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`).\n- Chinese prose *SHOULD* prefer colons, periods, commas, or parentheses over em dashes. Keep an em dash only when no other punctuation preserves the sentence naturally.\n- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas.\n- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always.\n- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code.\n- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice).\n- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration.\n\n## Quality bar\n\n- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra.\n- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Human review owns list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.\n\n## References\n\nAuthorities cited by these rules, for humans and agents who want the underlying reasoning:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation.\n- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice.\n- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team.\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone.\n- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides.\n- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines.\n- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize.\n" + }, + { + "role": "assistant", + "content": "# 翻译规则\n\n[English](translation-rules.md) | 中文\n\n本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效。日常工作中,agent 会在术语指导下直接一次完成有改动内容的翻译;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时运行。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。\n\n## 忠实性\n\n- 对侧文件*必须*传达与撰写侧相同的内容:不添加行为、前置条件、警告、版本声明或示例,也不漏掉任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜;请修正错误的一侧,并在同一个变更里同步更新另一侧。\n- 对侧文件读起来*应当*是其语言自然的技术文字,而非逐词对照的译文。请根据语义翻译,在目标语言语法需要时重组句子,并保持原作者的语域(比如:简练的保持简练)。\n- 不要翻译不可译的内容:如果一句话依赖源语言的习语、无法自然转换,请翻译它的意思,而非习语本身。\n\n## 行文\n\n- 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。\n- 以母语技术作者的身份重述内容,而不是以译者身份逐句转写,同时保留原文的每个语义成分:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。\n- 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。\n- 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。\n- 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。\n- 翻译为中文时,类别名词使用中文并在首现括注英文(实操手册(cookbook));翻译为英文时,使用通行的英文类别名。指目录或文件本身时保留代码体英文。\n\n## 结构保持\n\n配对门禁会检查标题深度、围栏代码块、表格行列数、列表类型、有序列表起始编号、列表项数量与链接目标;门禁未覆盖的结构仍需人工核对。两个配对文件必须在以下方面一一对应:\n\n- 标题层级(相同级别、相同顺序;标题的**文字**要翻译);\n- 列表形态与编号;\n- 表格(相同的列、相同的行序;表头单元格按术语表翻译);\n- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;\n- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;\n- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。\n\n本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。\n\n## 术语\n\n- [terminology.md](terminology.md) 是双向的术语真源。翻译前请先加载它;表内术语必须遵守对应行与「不要译作」禁项。译成中文时,采用「中文」列,并按「首次出现」列括注;译成英文时,采用「English」列,不加中文括注。\n- 译成中文时,术语表未收录的技术术语只有在主流中文 OSS 文档或厂商资料中已有通行译法时才可以翻译(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档),并须在 PR 中注明出处;否则必须保留英文,并在 PR 描述的「待定术语」中给出建议译法。\n- 译成英文时,采用通行的英文技术术语。如果源术语没有明确的通行对应词,则保留原词、附上简短说明,并列入「待定术语」。两个方向都不得自行创造译法;确定后的术语须在同一个 PR 或后续 PR 中加入 [terminology.md](terminology.md)。\n\n## 排版\n\n本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。以下中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) 与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011:\n\n- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。\n- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。\n- 中文行文*应当*优先使用冒号、句号、逗号或括号,尽量不用破折号;只有其他标点都无法自然表达时才保留破折号。\n- 顿号:中文的并列项之间使用顿号(、),而非逗号。\n- 禁止使用全角数字或全角拉丁字母:永远不写 `123`,永远写 `123`。\n- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek。除非引用代码,否则绝不写 `github`/`Github`。\n- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。\n- 强调标记(`**加粗**`、`*斜体*`)落在与对侧相同的文字段上。中文没有斜体,渲染效果可能看不出差别,不要用引号或其他装饰替代。\n\n## 质量标准\n\n- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。\n- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体则由人工评审负责。\n\n## 参考资料\n\n本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines):中西文混排空格与标点的社区事实标准。\n- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md):与本文同形态的仓库内置翻译规则文件;空格、标点与术语表实践。\n- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/):最大的中文本地化团队的术语首现与标点实践。\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5):逐术语的译/留决策与语气。\n- [zh-style-guide](https://zh-style-guide.readthedocs.io):社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。\n- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides):排版学与厂商本地化的正式基线。\n- GB/T 19682-2005《翻译服务译文质量要求》:国家标准;本文「忠实性」与「术语」两节将其三项基本要求(忠实原文、术语统一、行文通顺)落实为可操作的规则。\n" + }, + { + "role": "user", +<<<<<<< HEAD "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write <pair>`, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, the Chinese side and every authored English source carry their switchers while listed generated English sources are exempt, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — `.zh.md` files would carry an HTML comment recording the English source's blob hash, and translation would flow EN → ZH only. Rejected: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus a committed agent skill in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated English documents remain derived from source and freshness-gated by their owning generators. A generated page with a reviewed Chinese counterpart participates in the three-file pairing workflow, with one structural exception: the generated English source has no language switcher because adding one would make the generator stale, while the Chinese counterpart links back to it. Generated pages without a reviewed counterpart remain explicit exclusions and use an English website projection.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.\n" }, { "role": "assistant", "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write <pair>`,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、中文侧和所有普通撰写的英文源都带切换行而清单内的生成英文源除外、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。\n\n## 验证\n\n验证约定分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。否决:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个仓库内置的 agent skill 替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成的英文文档仍由源码派生,并由各自的生成器实施新鲜度门禁。有经评审中文对侧的生成页面遵循三文件配对工作流,但有一项结构例外:生成的英文源文件不含语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。没有经评审对侧的生成页面保留为显式排除项,并在网站上投影英文。\n- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" +======= + "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write <pair>`, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** Routine changes use the direct one-pass path owned by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). The [extended translation skill](../../../skills/dsh-translate-docs/SKILL.md) retains delegated translation and the other heavier mechanisms for explicit user invocation; both paths defer to the documentation contracts as their sources of truth.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — the design first proposed for this Agent Note: `.zh.md` files carried an HTML comment recording the English source's blob hash, and translation flowed EN → ZH only. Revised in review: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus an agent-run workflow in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.\n" + }, + { + "role": "assistant", + "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write <pair>`,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 常规改动采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.md)确立的直接单遍路径。[扩展翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)保留委派翻译和其他较重机制,供用户显式调用;两条路径均以文档契约为真源。\n\n## 验证\n\n验证契约分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:本 Agent Note 最初提出的设计:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。评审中修订:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个由 agent 运行的工作流替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" +>>>>>>> 45777c7624 (test(snapshot): refresh translation prompt fixture) }, { "role": "user", From 86d5dd438437fbc7b5b1e57d97ccc03d8fbd3eb4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 18:02:11 +0800 Subject: [PATCH 101/229] fix(preset): align minimal agent with RL composition --- ...026-08-09-layered-skill-registry.i18n.yaml | 4 +- .../2026-08-09-layered-skill-registry.md | 2 +- .../2026-08-09-layered-skill-registry.zh.md | 2 +- ...nimal-preset-owns-rl-composition.i18n.yaml | 6 + ...8-10-minimal-preset-owns-rl-composition.md | 37 +++++ ...0-minimal-preset-owns-rl-composition.zh.md | 37 +++++ ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 4 +- ...9-persistent-bash-str-replace-editor.zh.md | 4 +- ...ssion-search-not-shipped-default.i18n.yaml | 4 +- ...8-02-session-search-not-shipped-default.md | 2 +- ...2-session-search-not-shipped-default.zh.md | 2 +- .../agent-presets/minimal/agent.cordis.yml | 88 +++++++---- .../config/agent-presets/minimal/preset.yml | 2 +- apps/cli/config/core-web.cordis.yml | 113 --------------- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 4 +- apps/cli/reference/README.zh.md | 4 +- apps/cli/tests/built-bin.e2e.ts | 12 -- apps/cli/tests/web-agent-presets.e2e.ts | 26 +++- apps/web/tests/core-web-profile.snapshot.ts | 137 ------------------ apps/web/tests/minimal-preset.snapshot.ts | 115 +++++++++++++++ .../session.jsonl | 8 +- apps/web/tsconfig.json | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 4 +- docs/event-producer-consumer.zh.md | 4 +- docs/subsystems/system-prompt.i18n.yaml | 4 +- docs/subsystems/system-prompt.md | 27 +++- docs/subsystems/system-prompt.zh.md | 27 +++- packages/bundle/web-app/cordis.patch.yml | 6 +- packages/core/system-prompt/README.i18n.yaml | 4 +- packages/core/system-prompt/README.md | 12 +- packages/core/system-prompt/README.zh.md | 12 +- packages/core/system-prompt/src/index.ts | 45 ++++-- .../system-prompt/tests/system-prompt.spec.ts | 28 ++++ .../tests/api-proxy-agent-preset.spec.ts | 22 +-- packages/preset/persona/README.i18n.yaml | 4 +- packages/preset/persona/README.md | 9 +- packages/preset/persona/README.zh.md | 9 +- packages/preset/persona/src/index.ts | 6 +- packages/preset/persona/src/invariant.ts | 3 +- packages/preset/persona/tests/persona.spec.ts | 17 +++ .../tool-cordis/src/api-catalog.ts | 6 +- tsconfig.host.json | 2 +- 48 files changed, 484 insertions(+), 406 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md delete mode 100644 apps/cli/config/core-web.cordis.yml delete mode 100644 apps/web/tests/core-web-profile.snapshot.ts create mode 100644 apps/web/tests/minimal-preset.snapshot.ts rename apps/web/tests/snapshots/{core-web-profile => minimal-preset}/session.jsonl (73%) diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml index 22e5090312..22296f97a9 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md -2026-08-09-layered-skill-registry.md: 3f092cfb4b722e3dd51fa4dc46c620259eaffa39 -2026-08-09-layered-skill-registry.zh.md: 38b17329c8d46ee9bbd0863f3fae7cf6be39aa75 +2026-08-09-layered-skill-registry.md: 73897c3cb7e0055ff59221b7ea47c5d6ced06991 +2026-08-09-layered-skill-registry.zh.md: 655780d4ef154434d6debf478134d3293d6c564f diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md index 3f092cfb4b..73897c3cb7 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md @@ -24,7 +24,7 @@ The composition moves with it: the web-app bundle re-enables the base `skill` re **A deployment-level skill reaches every preset-composed session that mounts `tool-skill`.** The repository-plugin e2e's skill root and assertions are restored; the shipped-Web e2e proves the badge row (the same host-registration shape) merges into a standard-preset agent's catalog while the host view stays global-only. -**Layer visibility and consumption stay separate choices.** A core-web agent can read the global layer in principle, but composes no `skill` tool — whether an agent has skills at all remains the preset's decision, made by mounting or omitting `tool-skill`. +**Layer visibility and consumption stay separate choices.** A `minimal` agent can read the global layer in principle, but composes no `skill` tool — whether an agent has skills at all remains the preset's decision, made by mounting or omitting `tool-skill`. **Provider options are still the borrowed caller object.** `SkillViewOptions` extends `SkillLookupOptions`; the registry consumes `scope` and providers read only their own contract from the same readonly object, preserving the existing borrow-identity guarantee. diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md index 38b17329c8..655780d4ef 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md @@ -24,7 +24,7 @@ agent-preset stack 曾把整个 skill 能力——注册表、本地提供方和 **部署级 skill 会到达每个挂载 `tool-skill` 的 preset 会话。**repository-plugin e2e 的 skill 根目录与断言已恢复;shipped-Web e2e 证明 badge 行(同一种宿主注册形态)汇入 standard preset agent 的目录,而宿主视图保持仅全局。 -**层可见性与消费仍是两个独立选择。**core-web agent 原则上可读全局层,但不组合 `skill` 工具——agent 是否拥有 skill 依旧由 preset 通过挂载或省略 `tool-skill` 决定。 +**层可见性与消费仍是两个独立选择。** `minimal` agent 原则上可读全局层,但不组合 `skill` 工具——agent 是否拥有 skill 依旧由 preset 通过挂载或省略 `tool-skill` 决定。 **提供方选项仍是借用的调用方对象。**`SkillViewOptions` 扩展 `SkillLookupOptions`;注册表消费 `scope`,提供方只从同一个只读对象中读取自己的契约,保持既有的借用恒等保证。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml new file mode 100644 index 0000000000..6861aff43a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.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-minimal-preset-owns-rl-composition.md +2026-08-10-minimal-preset-owns-rl-composition.md: 043f2e45e3fe4fbb92aa6652ce099ebfde09de55 +2026-08-10-minimal-preset-owns-rl-composition.zh.md: 83f243b56b25237f19fa288f87e15eee6a264c94 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md new file mode 100644 index 0000000000..043f2e45e3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md @@ -0,0 +1,37 @@ +# Agent Note: The minimal preset owns the complete RL agent composition + +Status: implemented + +English | [中文](2026-08-10-minimal-preset-owns-rl-composition.zh.md) + +## Problem + +The Web surface offered two owners for the Claude SWE-compatible RL agent: a process-wide `core-web.cordis.yml` patch and the per-session `minimal` preset. Once [agent presets](../architecture/2026-08-03-per-session-agent-presets.md) became the agent-composition boundary, the preset's scoped `deployment:persona` shadowed the overlay's corrected global persona with stale coding-agent text. The overlay test mounted no preset, while the preset test booted without the overlay, so neither exercised the composition users selected. + +The split also hid other drift. The preset mounted one-shot Bash rather than the [persistent Bash](../feature/2026-07-29-persistent-bash-str-replace-editor.md) used by the RL harness and omitted the RL compaction policy. Keeping both owners makes every future prompt, tool, and policy change a cross-product. + +## Decision + +The shipped `minimal` preset is the sole RL agent composition. It declares an entry-local PTY registry and local backend, persistent `bash` with the RL environment description and 300-second timeout, `str_replace_editor`, and an entry-local compaction backend. Tool presentation remains a deployment choice. The compaction policy keeps the RL threshold, absolute retention, generation cap, and retry count; model capacity comes from routed adapter metadata because `contextWindow` is no longer a compact-basic config field. The editor accepts no `requireAbsolutePath` setting because absolute paths are its unconditional contract. + +The preset persona is exactly `You are a helpful software engineer assistant.` and sets `complete: true`. A complete `PromptSection` participates in ordinary assembly so tools, contexts, variables, and cooperative listeners still resolve; after the `system-prompt/assemble` waterfall, the prompt registry restores a detached copy of that section as the sole system-prompt section. Multiple effective complete sections reject assembly. This final registry constraint prevents harness identity, Web orientation, tool guidance, or an assembly listener from appending prompt text. + +The process-wide `core-web.cordis.yml` patch is absent. Browser UI, workspace attachment, persistence, filesystem, subprocess, sandbox, permission, model routing, and other cross-session services remain host-owned. Selecting `minimal` changes one agent's model-facing composition without changing other sessions in the Web process. + +## Verification + +System-prompt and persona package tests prove final complete-section enforcement, including waterfall mutation and duplicate rejection. The shipped-preset composition test asserts the exact prompt, Bash description, absolute editor schema, and two-tool catalog under the default native presentation. The keyless Web replay sends a real request through a `minimal` agent while global identity, Web surface text, and a test section are registered, then executes two persistent Bash calls to prove environment and cwd state survive and executes the editor through an absolute path. + +## Alternatives considered + +**Keep `core-web.cordis.yml` as a compatibility patch.** Rejected because a process patch and a session preset are two independent owners for one agent contract; precedence makes either one capable of silently undoing the other. + +**Disable every known prompt contributor in the preset.** Rejected because host rows are process-wide and new contributors would reopen the prompt. A final complete-section constraint expresses the negative guarantee at the registry that assembles the prompt. + +**Filter sections only with a prepended waterfall listener.** Rejected because another prepended wrapper can run outside it and append after the filter. Enforcement after the complete waterfall has stable final authority. + +**Mount PTY services on the Web host.** Rejected because only the minimal agent consumes them. An entry-local `pty` realm gives the services the same lifetime and scope as their sole consumer without publishing a process-global service from a preset. + +## Consequences + +The RL prompt is fixed rather than environment-overridable, and `minimal` is the only shipped place that states it. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The preset pays for its own PTY and compaction service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset is not a Windows agent surface. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md new file mode 100644 index 0000000000..83f243b56b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md @@ -0,0 +1,37 @@ +# Agent Note: minimal preset 拥有完整的 RL agent 组合 + +Status: implemented + +[English](2026-08-10-minimal-preset-owns-rl-composition.md) | 中文 + +## 问题 + +Web surface 同时由两个位置定义与 Claude SWE 兼容的 RL agent(智能体):进程级 `core-web.cordis.yml` patch,以及逐会话的 `minimal` preset。[agent preset](../architecture/2026-08-03-per-session-agent-presets.md) 成为 agent 组合边界后,preset 中带作用域的 `deployment:persona` 会用陈旧的 coding-agent 文本遮蔽 overlay 修正过的全局 persona。overlay 测试没有挂载 preset,而 preset 测试启动时没有 overlay,因此两者都没有覆盖用户实际选择的组合。 + +这种拆分还掩盖了其他偏差。preset 挂载了一次性 Bash,而不是 RL harness 使用的[持久 Bash](../feature/2026-07-29-persistent-bash-str-replace-editor.md),并且遗漏了 RL 压缩(compaction)策略。保留两个所有者,会使今后每次修改提示词、工具或策略时都必须验证二者的交叉组合。 + +## 决策 + +随附的 `minimal` preset 是 RL agent 组合的唯一所有者。它声明 entry 本地的 PTY 注册表与本地后端、带 RL 环境描述且超时为 300 秒的持久 `bash`、`str_replace_editor`,以及 entry 本地的压缩后端。工具呈现仍由部署选择。压缩策略保留 RL 的阈值、绝对保留量、生成上限和重试次数;模型容量来自经路由选定的适配器元数据,因为 `contextWindow` 已不再是 compact-basic 的配置字段。编辑器不接受 `requireAbsolutePath` 设置,因为要求绝对路径是它的无条件约定。 + +preset persona 恰好是 `You are a helpful software engineer assistant.`,并设置 `complete: true`。complete `PromptSection` 参与常规组装,因此工具、上下文、变量和协作式监听器仍会解析;`system-prompt/assemble` waterfall(瀑布式事件)结束后,提示词注册表会将该段落的独立副本恢复为唯一的系统提示词段落。存在多个有效 complete 段时,组装会被拒绝。这项最终注册表约束可防止 harness 身份、Web 定位、工具引导或组装监听器追加提示词文本。 + +进程级 `core-web.cordis.yml` patch 不再存在。浏览器 UI、workspace 附加、持久化、文件系统、子进程、沙箱、权限、模型路由及其他跨会话服务仍由宿主持有。选择 `minimal` 只会改变一个 agent 面向模型的组合,不会改变 Web 进程中的其他会话。 + +## 验证 + +系统提示词与 persona 包测试证明了 complete 段的最终约束,包括 waterfall 修改与重复项拒绝。交付 preset 组合测试在默认原生呈现下断言精确的提示词、Bash 描述、要求绝对路径的编辑器 schema 和双工具目录。无密钥 Web 回放通过 `minimal` agent 发送一个真实请求,同时注册全局身份、Web surface 文本和一个测试段落;随后执行两次持久 Bash 调用,证明环境与 cwd 状态能够保留,并通过绝对路径执行编辑器。 + +## 考虑过的替代方案 + +**将 `core-web.cordis.yml` 保留为兼容 patch。** 被拒绝,因为进程 patch 与会话 preset 是同一 agent 约定的两个独立所有者;优先级会使任意一方都能静默撤销另一方的配置。 + +**在 preset 中禁用每个已知的提示词贡献方。** 被拒绝,因为宿主行属于整个进程,新的贡献方也会重新开放提示词。由组装提示词的注册表实施最终 complete 段约束,才能表达这项否定保证。 + +**仅使用前置 waterfall 监听器筛选段落。** 被拒绝,因为另一个前置包装层可以在该监听器外执行,并在筛选后追加内容。在整个 waterfall 结束后实施约束,才能稳定拥有最终决定权。 + +**在 Web 宿主上挂载 PTY 服务。** 被拒绝,因为只有 minimal agent 消费这些服务。entry 本地的 `pty` realm 与唯一消费方具有相同的生命周期和作用域,无需由 preset 发布进程级全局服务。 + +## 后果 + +RL 提示词固定不变,不能通过环境覆盖,且 `minimal` 是交付内容中唯一声明该提示词的位置。模型只看到持久 `bash` 与 `str_replace_editor`;shell 状态按 agent 隔离,并随该 agent 一并消失。preset 为自身的 PTY 与压缩服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不适用于 Windows agent surface。 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index 42a8e3e6bd..fbe84849b4 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: c4750e30370bfd253064c39cb1adc0f5b2baa60d -2026-07-29-persistent-bash-str-replace-editor.zh.md: 83159d9792fd9fadaaa342cc289300b35da34e4a +2026-07-29-persistent-bash-str-replace-editor.md: 2375ad7e40afb096d7e1bbec4de023433de1e012 +2026-07-29-persistent-bash-str-replace-editor.zh.md: fcabc4bd342224a8b2d024a48901af284b4c6d2e diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index c4750e3037..2375ad7e40 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -18,7 +18,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable. -The shipped [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) overlay composes both plugins over the ordinary Web surface for the Claude SWE-compatible RL contract. It pins native tool mode and makes the complete system prompt `DSH_SYSTEM_PROMPT` when set or `You are a helpful software engineer assistant.` otherwise, with no harness identity, source-checkout section, Web orientation, Workspace instructions, or tool-mode guidance. It disables every other model-facing consumer, so the model receives exactly the persistent `bash` and `str_replace_editor` schemas, while the Web host, browser, Workspace, persistence, sandbox, and permission stack remains in place. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. +The shipped [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes native presentation and the complete system prompt, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary. ## Alternatives considered @@ -32,4 +32,4 @@ The shipped [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis. ## Consequences -Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. The Core Web profile retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. +Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. A minimal Web agent retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 83159d9792..fcabc4bd34 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -18,7 +18,7 @@ Status: implemented 两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。 -已交付的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) overlay 会在常规 Web 界面之上组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。它固定使用原生工具模式;完整的系统提示词在设置 `DSH_SYSTEM_PROMPT` 时采用其值,否则采用 `You are a helpful software engineer assistant.`,且不包含 harness 身份、源码 checkout 提示词段、Web 界面定位、Workspace 指令或工具模式指引。它会禁用其他所有面向模型的消费方,使模型恰好只收到持久 `bash` 和 `str_replace_editor` 两个 schema,同时保留 Web 宿主、浏览器、Workspace、持久化、沙箱与权限栈。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。 +随附的 [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定原生呈现和完整系统提示词,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)负责说明。 ## 考虑过的替代方案 @@ -32,4 +32,4 @@ Status: implemented ## 后果 -Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。Core Web profile 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 +Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。minimal Web agent 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml index fd479c217e..1a4d923540 100644 --- a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md -2026-08-02-session-search-not-shipped-default.md: 65bd72fff76210b726e7562fb8e88e5f8802434a -2026-08-02-session-search-not-shipped-default.zh.md: 4eb0851c1e584b84847b6bb5118c8bb2f3156845 +2026-08-02-session-search-not-shipped-default.md: c1bfd7f8e354a4480c5635619514fe782ea71d2c +2026-08-02-session-search-not-shipped-default.zh.md: 9b80c549425c26055700480dd57f1a0a7d01e4a8 diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md index 65bd72fff7..c1bfd7f8e3 100644 --- a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md @@ -10,7 +10,7 @@ The [shipped-roster decision](2026-07-31-even-out-shipped-tool-rosters.md) made ## Decision -The shipped TUI, Web, and headless surfaces no longer mount `@deepseek-ai/dsh-tool-session-query`: the row is removed from the shared `cordis.patch.yml`, the now-dangling `disabled` patch in the opt-in [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile goes with it, and the workspace dependency drops from `apps/cli/package.json`. The consumer stays opt-in exactly as the model-facing-session-query-tools note describes: the ACP example's [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) and its snapshot counterpart remain the mounted reference, and a custom composition can mount the package with the timeout and spill policies. +The shipped TUI, Web, and headless surfaces do not mount `@deepseek-ai/dsh-tool-session-query`, and no shipped agent preset carries it. The consumer stays opt-in exactly as the model-facing-session-query-tools note describes: the ACP example's [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) and its snapshot counterpart remain the mounted reference, and a custom composition can mount the package with the timeout and spill policies. The `ctx.sessionQuery` service itself stays mounted. `session-query-sqlite` remains a base row — the TUI's `session-reference` consumes it for `/resume` — and the Web overlay keeps patching it to an in-memory index for the browser content search. Only the model-facing consumer is removed. diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md index 4eb0851c1e..9b80c54942 100644 --- a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -交付的 TUI、Web 与无头 surface 不再挂载 `@deepseek-ai/dsh-tool-session-query`:该行从共享的 `cordis.patch.yml` 移除,opt-in 的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile 中那条已悬空的 `disabled` patch 也随之删除,workspace 依赖也从 `apps/cli/package.json` 中移除。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP(Agent Client Protocol)示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。 +交付的 TUI、Web 与无头 surface 均不挂载 `@deepseek-ai/dsh-tool-session-query`,交付的 agent preset 也都不包含它。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP(Agent Client Protocol)示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。 `ctx.sessionQuery` 服务本身保持挂载。`session-query-sqlite` 仍是 base 的一行,TUI 的 `session-reference` 消费它来实现 `/resume`,Web overlay 也继续把它 patch 成内存索引,供浏览器内容搜索使用。被移除的只有面向模型的消费方。 diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 6ae88b9339..44d1bb45df 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -1,39 +1,71 @@ -# The `minimal` agent preset: the two-tool benchmark surface. +# The `minimal` agent preset: the Claude SWE-compatible RL surface. # -# The native model surface is exactly persistent `bash` plus -# `str_replace_editor`. Everything else a session could reach — skills, goals, -# plan mode, delegation, workflows, todo, web — is simply absent rather than -# disabled, because a preset composes what an agent has instead of subtracting -# from a shared default. -# -# The host composition is unchanged: this agent still runs inside the same -# sandbox, approval, persistence, and model routing as any other session. +# The persona is the complete system prompt, so global identity, Web surface, +# tool guidance, and later assembly listeners cannot add prompt text. The model +# composes only the persistent `bash` and `str_replace_editor` tools. - id: persona name: '@deepseek-ai/dsh-persona' config: - text: >- - You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + text: You are a helpful software engineer assistant. + complete: true -# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to -# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is -# the criterion for host-plane ownership — injection resolves before any session -# exists, so there is no agent to key by. Behind a preset realm those variables -# never reached the model's shell at all. `tool-bash` consumes the host registry -# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the -# sandbox policy owns it. -# -# `run_in_background` is off because this preset mounts no `tool-tasks`. The -# host registry already refuses a start for an owner no attached control -# surface serves, so this is not the safety boundary — it is the model-facing -# one: an agent that could never collect a task should not be offered the -# parameter at all, and disabling it drops the parameter from the schema. -- id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' +# The PTY registry is an agent-owned service, so it lives in an entry-local +# realm. The backend still consumes the host sandbox policy and subprocess +# implementation, while the tool registers into this agent's scoped catalog. +- id: persistent-shell + name: cordis:group + group: true + isolate: + pty: true config: - enableRunInBackground: false + - id: pty + name: '@deepseek-ai/dsh-pty' -- id: tool-str-replace-editor + - id: pty-local + name: '@deepseek-ai/dsh-pty-local' + config: + timeoutMs: 300000 + + - id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 + description: |- + Run commands in a bash shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * You do have access to a mirror of common linux and python packages via apt and pip. + * State is persistent across command calls and discussions with the user. + * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + +# Absolute paths are unconditional in the current editor; the legacy +# `requireAbsolutePath` switch is no longer a configuration field. +- id: str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' config: maxOutputChars: 16000 + +# RL core's fixed 128K window now comes from the routed model metadata rather +# than compact-basic config. Its remaining policy is preserved explicitly. +- id: compaction + name: cordis:group + group: true + isolate: + tokenMeter: true + compact: true + config: + - id: token-meter + name: '@deepseek-ai/dsh-token-meter' + + - id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + thresholdRatio: 0.8 + retainTokens: 20480 + summarizationProvider: '' + summarizationModel: '' + maxTokens: 8192 + compactionRetries: 1 diff --git a/apps/cli/config/agent-presets/minimal/preset.yml b/apps/cli/config/agent-presets/minimal/preset.yml index 5521dda140..86366626e1 100644 --- a/apps/cli/config/agent-presets/minimal/preset.yml +++ b/apps/cli/config/agent-presets/minimal/preset.yml @@ -1,3 +1,3 @@ name: 极简模式 -description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 +description: 固定 RL 系统提示词,只呈现持久 bash 与 str_replace_editor。 order: 3 diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml deleted file mode 100644 index 43860418c4..0000000000 --- a/apps/cli/config/core-web.cordis.yml +++ /dev/null @@ -1,113 +0,0 @@ -# Opt-in Web shell for the RL core agent contract. The model receives exactly -# the configured persona plus the native `bash` and `str_replace_editor` -# schemas; the Web host, browser shell, persistence, and permission stack stay. - -# Match the Claude SWE-compatible RL core prompt. Disabling the Web runtime's -# surface context removes its GUI orientation, managed shell variables, and the -# launcher's source-checkout section through one configuration contract. -# Workspace instructions are model-visible user context rather than a system -# section, but RL core disables them as part of the same prompt contract. -- id: system-prompt - config: - includeHarnessIdentity: false - persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' - -- id: web-runtime - config: - surfaceContext: false - -- id: workspace-context - disabled: true - -- id: tools - config: - mode: native - -# Disable every model-facing consumer in the base/Web tree. plan-mode owns the -# always-registered exit_plan_mode tool even while the session is not planning. -- id: tool-bash - disabled: true - -- id: tool-tasks - disabled: true - -- id: tool-fs - disabled: true - -- id: tool-fs-search - disabled: true - -- id: tool-web - disabled: true - -- id: tool-skill - disabled: true - -- id: plan-mode - disabled: true - -- id: tool-subagent-control - disabled: true - -- id: tool-subagent-list-agents - disabled: true - -- id: tool-subagent - disabled: true - -- id: tool-subagent-fork - disabled: true - -- id: tool-workflow - disabled: true - -- id: tool-todo - disabled: true - -# These consumers are shared defaults on the ordinary shipped surfaces, but -# this opt-in profile keeps exactly its two named tools. -- id: tool-goal - disabled: true - -- id: tool-ralph - disabled: true - -- id: tool-str-replace-editor - disabled: true - -# The matching browser controls must not offer surfaces whose tool this -# overlay omits: the panels would render for a capability the model does not -# have. Turning the row off no longer removes a tool — `ui-question`'s host -# half is empty and `tool-ask-user` is composed per preset — so this is a UI -# decision now, not a capability one. -- id: ui-plan - disabled: true - -- id: ui-question - disabled: true - -- insert: - - id: pty - name: '@deepseek-ai/dsh-pty' - - # This backend consumes the existing Web sandbox and permission policy. - # It loads only on Linux/macOS; Windows and other platforms fail at boot. - # Its 300s send wait matches the persistent Bash command timeout instead of - # pty-local's 30s default. An open persistent shell fences permission-mode - # changes until it closes. - - id: pty-local - name: '@deepseek-ai/dsh-pty-local' - config: - timeoutMs: 300000 - - - id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' - config: - timeoutMs: 300000 - - # The editor consumes the Web fs-sandbox provider and therefore retains - # the selected session permission mode. - - id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 4b5aed6cd2..bb2e240f91 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 0b5faf8993cd8065fffcfec5f240b0084508db91 -README.zh.md: b9c48c16dd4be186266d30a438329463c31aca70 +README.md: 12574a369acf2697842ae3aae95ce152d52c009d +README.zh.md: f80dfba10292a03b1d855481bf4fa947a42a53c2 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 0b5faf8993..12574a369a 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -59,9 +59,7 @@ All modes treat the invoking directory as the default workspace root, load appli New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. -`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional RL-compatible `--patch` overlay that pins native mode, renders only `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.` as the system prompt, disables Workspace instructions and every Web runtime prompt contribution, and exposes only persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition. - -`DSH_SYSTEM_PROMPT` is passed as the system-prompt [`persona`](../../../packages/core/system-prompt/README.md#config): complete `{{…}}` groups use that contract's strict variable interpolation rules and have no literal-brace escape; any set value, including an empty string, is authoritative and an empty value therefore removes the system prompt, while only an unset variable selects the fallback. +`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. ## Shared deployment behavior diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index b9c48c16dd..f80dfba102 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -59,9 +59,7 @@ dsh web --dump-config 新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。 -`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选的 RL 兼容 `--patch` overlay:它固定使用 `native` 模式,仅将 `DSH_SYSTEM_PROMPT` 或 `You are a helpful software engineer assistant.` 渲染为系统提示词,禁用 Workspace 指令与所有 Web 运行时提示词贡献,并且在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,仅暴露持久 `bash` 和 `str_replace_editor`。 - -`DSH_SYSTEM_PROMPT` 会传给系统提示词的 [`persona`](../../../packages/core/system-prompt/README.md#config):完整的 `{{…}}` 分组遵循该约定的严格变量插值规则,且无法转义为字面花括号;任何已设置的值(包括空字符串)都具有权威性,因此空值会移除系统提示词,只有未设置该变量时才会选择后备值。 +`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 ## 共享部署行为 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index b81f837cf8..128fbbd42c 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -9,7 +9,6 @@ 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)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -const coreWebOverlay = fileURLToPath(new URL('../config/core-web.cordis.yml', import.meta.url)) const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url)) async function runBuiltBin( @@ -543,16 +542,5 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`) expect(stderr).toContain('patch: entry "absent-row" not found') }, 30_000) - - it('shows the RL Web patch disabling runtime surface context', async () => { - const { stdout, code, stderr } = await runBuiltBin( - ['web', '--patch', coreWebOverlay, '--dump-config'], - { DSH_HOME: home }, - ) - expect(code).toBe(0) - expect(stderr).toBe('') - expect(stdout).toContain("name: '@deepseek-ai/dsh-web-app'") - expect(stdout).toContain('surfaceContext: false') - }, 30_000) }) }) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 1bfaed8c67..4a91016bf7 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -22,6 +22,15 @@ const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') /** The installation anchor whose dependency surface the preset module fallback mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') +const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.' +const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell +* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. +* You don't have access to the internet via this tool. +* You do have access to a mirror of common linux and python packages via apt and pip. +* State is persistent across command calls and discussions with the user. +* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. +* Please avoid commands that may produce a very large amount of output. +* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.` /** * Boot the shipped Web composition, minus the rows that would bind a port, @@ -143,14 +152,20 @@ describe('the shipped Web composition', () => { } }) - it('composes exactly two tools from `minimal`', async () => { + it('composes the exact RL prompt and two tools from `minimal`', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-minimal'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) try { - // Exactly what the preset lists — nothing arrives from the host. - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent }) + expect(assembly.sections).toEqual([ + { name: 'deployment:persona', text: MINIMAL_PROMPT }, + ]) + expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'str_replace_editor']) + expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION) + expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters)) + .toContain('Absolute path') } finally { await handle.dispose() } @@ -340,15 +355,14 @@ describe('the shipped Web composition', () => { expect(await readFile(path, 'utf8')).toBe(before) }) - it('gives each session its own persona', async () => { + it('gives each session its own complete persona', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-persona'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) try { const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent }) - expect(assembly.sections.find(section => section.name === 'deployment:persona')?.text) - .toContain('You are a coding agent powered by') + expect(assembly.sections).toEqual([{ name: 'deployment:persona', text: MINIMAL_PROMPT }]) } finally { await handle.dispose() } diff --git a/apps/web/tests/core-web-profile.snapshot.ts b/apps/web/tests/core-web-profile.snapshot.ts deleted file mode 100644 index 1178390837..0000000000 --- a/apps/web/tests/core-web-profile.snapshot.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import type { AgentHandle } from '@deepseek-ai/dsh-agent' -import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' -import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts' - -const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url)) -const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/core-web-profile', import.meta.url)) -const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') -const PROMPT = 'Reply exactly CORE_WEB_REQUEST_OK and stop.' - -describe('core Web profile', () => { - let scaffold: WebScaffold - let agentHandle: AgentHandle - - beforeAll(async () => { - const systemPrompt = process.env.DSH_SYSTEM_PROMPT - Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT') - try { - scaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE }) - } finally { - if (systemPrompt !== undefined) process.env.DSH_SYSTEM_PROMPT = systemPrompt - } - agentHandle = await scaffold.ctx.agents.create({ - sessionId: SessionId('core-web-profile-smoke'), - meta: { cwd: scaffold.workspaceCwd }, - agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }) - }) - - afterAll(async () => { - const failures: unknown[] = [] - await agentHandle?.dispose().catch((error: unknown) => failures.push(error)) - await scaffold?.close().catch((error: unknown) => failures.push(error)) - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'core Web profile smoke teardown failed') - }) - - it('sends the RL prompt and tool schemas through a real request, then executes both tools', async () => { - agentHandle.agent.followup(createUserMessage({ - content: [{ type: 'text', text: PROMPT }], - source: { kind: 'user' }, - })) - await agentHandle.agent.whenIdle() - - const requestHeader = agentHandle.agent.session.requestHeader() - if (requestHeader === undefined) throw new Error('the core Web agent issued no model request') - - const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt') - await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n') - const signal = new AbortController().signal - const bash = await scaffold.ctx.tools.execute({ - signal, - callId: CallId('core-web-bash-smoke'), - name: 'bash', - arguments: { command: "printf 'CORE_WEB_BASH_OK\\n'" }, - agent: agentHandle.agent, - }) - const editor = await scaffold.ctx.tools.execute({ - signal, - callId: CallId('core-web-editor-smoke'), - name: 'str_replace_editor', - arguments: { command: 'view', path: seedPath }, - agent: agentHandle.agent, - }) - - const text = (result: typeof bash): string => result.content - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') - .replaceAll(scaffold.workspaceCwd, '{{cwd}}') - .trimEnd() - - expect({ - prompt: requestHeader.system, - tools: requestHeader.tools?.map(tool => tool.name), - bash: text(bash), - editor: text(editor), - }).toMatchInlineSnapshot(` - { - "bash": "CORE_WEB_BASH_OK", - "editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines): - 1 CORE_WEB_EDITOR_OK - 2", - "prompt": "You are a helpful software engineer assistant.", - "tools": [ - "bash", - "str_replace_editor", - ], - } - `) - expect(requestHeader.tools).toEqual(scaffold.ctx.tools.schemas(agentHandle.agent)) - - const entries = [...scaffold.ctx.loader.entries()] - expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'web-runtime')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'workspace-context')?.fiber).toBeUndefined() - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl']) - }) - - it('uses DSH_SYSTEM_PROMPT as the complete prompt when configured', async () => { - const previous = process.env.DSH_SYSTEM_PROMPT - process.env.DSH_SYSTEM_PROMPT = 'RL prompt override' - let overrideScaffold: WebScaffold | undefined - let overrideAgent: AgentHandle | undefined - try { - overrideScaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE }) - overrideAgent = await overrideScaffold.ctx.agents.create({ - sessionId: SessionId('core-web-profile-override'), - meta: { cwd: overrideScaffold.workspaceCwd }, - agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }) - overrideAgent.agent.followup(createUserMessage({ - content: [{ type: 'text', text: PROMPT }], - source: { kind: 'user' }, - })) - await overrideAgent.agent.whenIdle() - expect(overrideAgent.agent.session.requestHeader()?.system).toBe('RL prompt override') - } finally { - try { - await overrideAgent?.dispose() - } finally { - try { - await overrideScaffold?.close() - } finally { - if (previous === undefined) Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT') - else process.env.DSH_SYSTEM_PROMPT = previous - } - } - } - }) -}) diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts new file mode 100644 index 0000000000..0c8c6fa765 --- /dev/null +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -0,0 +1,115 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/minimal-preset', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const PROMPT = 'Reply exactly MINIMAL_PRESET_REQUEST_OK and stop.' + +describe('minimal agent preset', () => { + let scaffold: WebScaffold + let agentHandle: AgentHandle + let disposeInjectedPrompt: () => void + + beforeAll(async () => { + scaffold = await launchWebScaffold({ replayFixture: FIXTURE }) + disposeInjectedPrompt = scaffold.ctx.systemPrompt.section({ + name: 'test:injected-prompt', + order: 999, + text: 'THIS TEXT MUST NOT REACH THE MODEL.', + }) + agentHandle = await scaffold.ctx.agents.create({ + sessionId: SessionId('minimal-preset-smoke'), + meta: { cwd: scaffold.workspaceCwd, agentPreset: 'minimal' }, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + }) + + afterAll(async () => { + const failures: unknown[] = [] + await agentHandle?.dispose().catch((error: unknown) => failures.push(error)) + try { + disposeInjectedPrompt?.() + } catch (error: unknown) { + failures.push(error) + } + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'minimal preset smoke teardown failed') + }) + + it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => { + agentHandle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: PROMPT }], + source: { kind: 'user' }, + })) + await agentHandle.agent.whenIdle() + + const requestHeader = agentHandle.agent.session.requestHeader() + if (requestHeader === undefined) throw new Error('the minimal agent issued no model request') + + const stateDir = join(scaffold.workspaceCwd, 'persistent-state') + await mkdir(stateDir) + const signal = new AbortController().signal + await scaffold.ctx.tools.execute({ + signal, + callId: CallId('minimal-bash-state-setup'), + name: 'bash', + arguments: { command: `cd ${JSON.stringify(stateDir)} && export DSH_MINIMAL_STATE=PERSISTED` }, + agent: agentHandle.agent, + }) + const bash = await scaffold.ctx.tools.execute({ + signal, + callId: CallId('minimal-bash-state-read'), + name: 'bash', + arguments: { command: 'printf \'%s:%s\n\' "$DSH_MINIMAL_STATE" "$PWD"' }, + agent: agentHandle.agent, + }) + const seedPath = join(scaffold.workspaceCwd, 'preset-smoke.txt') + await writeFile(seedPath, 'MINIMAL_EDITOR_OK\n') + const editor = await scaffold.ctx.tools.execute({ + signal, + callId: CallId('minimal-editor-smoke'), + name: 'str_replace_editor', + arguments: { command: 'view', path: seedPath }, + agent: agentHandle.agent, + }) + + const text = (result: typeof bash): string => result.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + .replaceAll(scaffold.workspaceCwd, '{{cwd}}') + .trimEnd() + + expect({ + prompt: requestHeader.system, + tools: requestHeader.tools?.map(tool => tool.name), + bash: text(bash), + editor: text(editor), + }).toMatchInlineSnapshot(` + { + "bash": "PERSISTED:{{cwd}}/persistent-state", + "editor": "Here's the content of {{cwd}}/preset-smoke.txt with line numbers (which has a total of 2 lines): + 1 MINIMAL_EDITOR_OK + 2", + "prompt": "You are a helpful software engineer assistant.", + "tools": [ + "bash", + "str_replace_editor", + ], + } + `) + expect(requestHeader.tools?.toSorted((left, right) => left.name.localeCompare(right.name))) + .toEqual(scaffold.ctx.tools.schemas(agentHandle.agent).toSorted((left, right) => left.name.localeCompare(right.name))) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl']) + }) +}) diff --git a/apps/web/tests/snapshots/core-web-profile/session.jsonl b/apps/web/tests/snapshots/minimal-preset/session.jsonl similarity index 73% rename from apps/web/tests/snapshots/core-web-profile/session.jsonl rename to apps/web/tests/snapshots/minimal-preset/session.jsonl index 04f0d62d15..49977be802 100644 --- a/apps/web/tests/snapshots/core-web-profile/session.jsonl +++ b/apps/web/tests/snapshots/minimal-preset/session.jsonl @@ -1,7 +1,7 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785974400000,"cwd":"{{cwd}}"} -{"type":"user/message","seq":0,"time":1785974400001,"data":{"content":[{"type":"text","text":"Reply exactly CORE_WEB_REQUEST_OK and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785974400000,"cwd":"{{cwd}}","agentPreset":"minimal"} +{"type":"user/message","seq":0,"time":1785974400001,"data":{"content":[{"type":"text","text":"Reply exactly MINIMAL_PRESET_REQUEST_OK and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} {"type":"assistant/chunk","seq":1,"time":1785974400002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":2,"time":1785974400003,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CORE_WEB_REQUEST_OK"}}} -{"type":"assistant/chunk","seq":3,"time":1785974400004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORE_WEB_REQUEST_OK"}}}} +{"type":"assistant/chunk","seq":2,"time":1785974400003,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"MINIMAL_PRESET_REQUEST_OK"}}} +{"type":"assistant/chunk","seq":3,"time":1785974400004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"MINIMAL_PRESET_REQUEST_OK"}}}} {"type":"assistant/chunk","seq":4,"time":1785974400005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}} {"type":"assistant/chunk","seq":5,"time":1785974400006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index da25f0cc59..6b3c7518de 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -24,7 +24,7 @@ "exclude": [ "tests/scaffold.ts", "tests/scaffold-hermetic.e2e.ts", - "tests/core-web-profile.snapshot.ts", + "tests/minimal-preset.snapshot.ts", "tests/live-interactions.e2e.ts", "tests/question-composer.e2e.ts", "tests/approval-composer.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 09c961ef69..bfbfbc9ec1 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: e6dd9ddf067202d7b60158b72008b8d8ab8adb87 +config-catalog.zh.md: 50d4c518b9ec079fe8402ea2f79f32e91cfbabe1 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 51c6ae46ee..e6dd9ddf06 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1144,6 +1144,8 @@ export interface Config { * variables. Empty text drops the section at render, matching the registry. */ text: string + /** Make this persona the complete system prompt, suppressing every other section. */ + complete?: boolean } ``` @@ -1986,7 +1988,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:177`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-time-context` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index dc93f5b4b5..50d4c518b9 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1146,6 +1146,8 @@ export interface Config { * variables. Empty text drops the section at render, matching the registry. */ text: string + /** Make this persona the complete system prompt, suppressing every other section. */ + complete?: boolean } ``` @@ -1988,7 +1990,7 @@ export interface Config { } ``` -来源:[`packages/core/system-prompt/src/index.ts:177`](../packages/core/system-prompt/src/index.ts) +来源:[`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-time-context` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index a2caf7b784..58c381e879 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: b78171ce51931f02a3f39ef98104ea9dedc27360 -event-producer-consumer.zh.md: c044385bf91559f5c4f82d99601642b932066e7f +event-producer-consumer.md: 7df7cb82db2b5c90556166f0ae8a7641a52c1b86 +event-producer-consumer.zh.md: 85528cb1b2acefc6bfdfd564674fb53710e1b3ce diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b78171ce51..7df7cb82db 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -41,8 +41,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | -| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index c044385bf9..85528cb1b2 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -43,8 +43,8 @@ | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | -| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index c24ae31019..a63870cd80 100644 --- a/docs/subsystems/system-prompt.i18n.yaml +++ b/docs/subsystems/system-prompt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/system-prompt.md -system-prompt.md: bdc0e994fb8e784a19814574c405d8cc3dce2d11 -system-prompt.zh.md: db6932b18f4721020fed567d49727f863eb06608 +system-prompt.md: 56617ef9d3d8da89673a4624abcef73e58d72cab +system-prompt.zh.md: cafea4f9689879b3fd8d0e1fff7249fcb02a7c12 diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index bdc0e994fb..56617ef9d3 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -39,7 +39,7 @@ interface ToolProviderResult { ## Prompt sections -`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. +`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. One effective `complete` section becomes the sole prompt section after cooperative assembly. ```ts type-equiv /** One contributed section of the system prompt (registry input). */ @@ -58,6 +58,13 @@ interface PromptSection { * interpolated later, by {@link renderPrompt}. */ readonly text: string | ((context: AssembleContext) => string) + /** + * Treat this contribution as the complete system prompt. Assembly still + * runs the cooperative waterfall so tools, contexts, and variables can be + * resolved, then restores this exact section as the sole prompt section. + * More than one effective complete section makes assembly fail. + */ + readonly complete?: boolean } ``` @@ -132,14 +139,16 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine /** * Assemble global and scoped providers, detach tool parameters, apply * canonical ordering, then run the assembly waterfall. Scoped sections and - * variables shadow globals; the returned waterfall value is authoritative. + * variables shadow globals. The returned waterfall value is authoritative + * except that an effective complete section is restored afterwards as the + * sole prompt section. * @param context - the optional scope and plugin-defined assembly fields. - * @returns the authoritative post-waterfall assembly. + * @returns the post-waterfall assembly with any complete prompt enforced. */ async assemble(context: AssembleContext = {}): Promise<PromptAssembly> ``` -Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:334`](../../packages/core/system-prompt/src/index.ts) <a id="system-prompt-events"></a> @@ -149,7 +158,7 @@ Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/sys #### `system-prompt/assemble` — waterfall -Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. +Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt. ```ts cordis-catalog /** @@ -157,7 +166,9 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners * receive only that scope's assemblies. The returned value is authoritative. * A supplied signal controls only this explicit assembly request and must not - * be retained to control later turns. + * be retained to control later turns. A registered complete section is + * restored after this waterfall, so listeners cannot add to or replace + * that scope's system prompt. * @param assembly - the mutable assembly built from registered providers. * @param context - the caller's per-assembly context. * @mode waterfall @@ -167,7 +178,7 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc Types: [Scoped](scope.md) -Source: [`packages/core/system-prompt/src/index.ts:29`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:31`](../../packages/core/system-prompt/src/index.ts) <a id="system-promptchange--emit"></a> @@ -184,5 +195,5 @@ Emitted when any prompt provider changes. This registry notification is unfilter 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:37`](../../packages/core/system-prompt/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index db6932b18f..cafea4f968 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -39,7 +39,7 @@ interface ToolProviderResult { ## 提示词段落 -`PromptSection` 是一份只读的同进程注册约定。其文本可以是静态的,也可以从当前组装上下文动态解析。 +`PromptSection` 是一份只读的同进程注册约定。其文本可以是静态的,也可以从当前组装上下文动态解析。协作式组装完成后,一个有效的 `complete` 段会成为唯一的提示词段落。 ```ts type-equiv /** One contributed section of the system prompt (registry input). */ @@ -58,6 +58,13 @@ interface PromptSection { * interpolated later, by {@link renderPrompt}. */ readonly text: string | ((context: AssembleContext) => string) + /** + * Treat this contribution as the complete system prompt. Assembly still + * runs the cooperative waterfall so tools, contexts, and variables can be + * resolved, then restores this exact section as the sole prompt section. + * More than one effective complete section makes assembly fail. + */ + readonly complete?: boolean } ``` @@ -132,14 +139,16 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine /** * Assemble global and scoped providers, detach tool parameters, apply * canonical ordering, then run the assembly waterfall. Scoped sections and - * variables shadow globals; the returned waterfall value is authoritative. + * variables shadow globals. The returned waterfall value is authoritative + * except that an effective complete section is restored afterwards as the + * sole prompt section. * @param context - the optional scope and plugin-defined assembly fields. - * @returns the authoritative post-waterfall assembly. + * @returns the post-waterfall assembly with any complete prompt enforced. */ async assemble(context: AssembleContext = {}): Promise<PromptAssembly> ``` -Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:334`](../../packages/core/system-prompt/src/index.ts) <a id="system-prompt-events"></a> @@ -149,7 +158,7 @@ Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/sys #### `system-prompt/assemble` — waterfall -Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. +Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt. ```ts cordis-catalog /** @@ -157,7 +166,9 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners * receive only that scope's assemblies. The returned value is authoritative. * A supplied signal controls only this explicit assembly request and must not - * be retained to control later turns. + * be retained to control later turns. A registered complete section is + * restored after this waterfall, so listeners cannot add to or replace + * that scope's system prompt. * @param assembly - the mutable assembly built from registered providers. * @param context - the caller's per-assembly context. * @mode waterfall @@ -167,7 +178,7 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc Types: [Scoped](scope.md) -Source: [`packages/core/system-prompt/src/index.ts:29`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:31`](../../packages/core/system-prompt/src/index.ts) <a id="system-promptchange--emit"></a> @@ -184,5 +195,5 @@ Emitted when any prompt provider changes. This registry notification is unfilter 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:37`](../../packages/core/system-prompt/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 216eb13199..6a1f2376c3 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -91,9 +91,9 @@ # assembly fact of dsh-web-app, never user config), mounts the # frontend-static fallback owner, registers the web-surface prompt # section and bash runtime variables, and prints the URL line. `dsh web` - # patches mode/lanAddresses over these defaults; complete-prompt overlays - # set surfaceContext false to suppress every model- and shell-visible Web - # runtime contribution. + # patches mode/lanAddresses over these defaults. A complete agent-preset + # persona suppresses the prompt section for that agent while retaining + # these host-owned shell variables. - id: web-runtime name: '@deepseek-ai/dsh-web-app' config: diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index 7d4e8f07bb..b1f068fa39 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md -README.md: 13b05bfcd19212ade42f22ece455871d022e6260 -README.zh.md: 0f9e7a2358134018975db1bc3c6b7206a274b3ec +README.md: cedda783d549633f5be9765a9a074e968d99500d +README.zh.md: 41729cdd1cfe6ebbd86f38c15bab5c50bd6ff7d2 diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 13b05bfcd1..cedda783d5 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -16,19 +16,19 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem ### Public API -- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber. +- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. A `complete: true` section becomes the exact complete prompt after the assembly waterfall; more than one effective complete section rejects assembly. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber. - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. -- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform waterfall. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name. +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform waterfall. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores an effective complete section as the sole prompt section. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects for multiple complete sections, when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name. ### Live events -`system-prompt/assemble` is authoritative; listeners that replace entries must preserve any active Code Mode or structured-output protocol. Use [`ToolRegistry.restrict()`](../tools/README.md) when filtering must stay aligned across presentation, lookup, and execution. Registry-change notifications are unfiltered. The generated region of [system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) owns signatures and dispatch contracts. +`system-prompt/assemble` is authoritative for ordinary sections; a complete section is the final prompt constraint applied after the waterfall. Listeners that replace entries must preserve any active Code Mode or structured-output protocol. Use [`ToolRegistry.restrict()`](../tools/README.md) when filtering must stay aligned across presentation, lookup, and execution. Registry-change notifications are unfiltered. The generated region of [system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) owns signatures and dispatch contracts. ### Key types - `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame. -- `PromptSection` — `{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. +- `PromptSection` — `{ name, order, text, complete? }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. One effective `complete` section suppresses all other sections after cooperative assembly. - `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. - `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned. @@ -39,7 +39,7 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse - Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`. - Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …). - Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically. -- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller. +- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller before any complete-section constraint is enforced. Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). @@ -49,7 +49,7 @@ Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/imple #### What the model sees -By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener for a deployment that owns the complete compatibility persona. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas. +By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The `system-prompt/assemble` waterfall determines the delivered prompt and tool schemas unless one effective section declares itself complete; that exact section then becomes the whole system prompt while the waterfall's contexts, tools, and variables remain. ##### Harness identity diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 0f9e7a2358..41729cdd1c 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -16,21 +16,21 @@ ### 公开 API -- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。 +- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。一个 `complete: true` 段会在组装 waterfall 之后成为精确的完整提示词;有效 complete 段超过一个时,组装会被拒绝。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。 - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void`:贡献工具 schema;每次组装时使用该次组装的上下文求值。`ToolProviderResult` = `{ schemas, knownNames? }`:`schemas` 是限制后的可见集合;`knownNames` 是限制前由 `toolOrder` 使用的全集。提供方不得返回名为 `TOOL_ORDER_REST` 的 schema。带作用域提供方只在其作用域的组装中查询。随调用 fiber 一并 dispose。 - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void`:贡献提示词变量,在段文本中以 `{{name}}` 引用。带作用域变量会为该 agent 遮蔽同名全局变量。同层重复或无法引用的名称会抛出;`undefined` 表示「本次组装没有值」。随调用 fiber 一并 dispose。 -- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 waterfall 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,并返回其权威结果。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。当已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。 +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 waterfall 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,之后将一个有效的 complete 段恢复为唯一的提示词段落。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。存在多个 complete 段、已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。 <a id="live-events"></a> ### 实时事件 -`system-prompt/assemble` 是权威来源;替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。[system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) 的生成区块拥有签名与分发约定。 +`system-prompt/assemble` 对普通段落具有权威性;complete 段是在 waterfall 之后应用的最终提示词约束。替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。[system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) 的生成区块拥有签名与分发约定。 ### 关键类型 - `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent`(类型化 DX 字段;绝不能在没有 `scope` 时设置,应使用 `assembleContextFor(agent, signal)`)。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。 -- `PromptSection`:`{ name, order, text }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。 +- `PromptSection`:`{ name, order, text, complete? }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。协作式组装完成后,一个有效的 `complete` 段会抑制其他所有段落。 - `PromptAssembly`:`{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`。段文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。 - `renderPrompt(assembly)`:插值每个段中的 `{{variable}}` 引用,删除空段,并用空行连接。严格规则:未知引用(使用 `Object.hasOwn` 查找,因此 `{{constructor}}` 等原型名称未知)、已注册但无值的引用、格式错误的完整 `{{…}}` 组,或一个起始 `{{` 没有打开完整组、但后面仍有 `}}`(`{{{model}}}`),都会抛出;明确失败胜过交付格式错误的提示词。孤立的 `{{` 如果后面任何位置都没有 `}}`,会按字面量通过;替换值绝不再次扫描。 @@ -41,7 +41,7 @@ - 段提供方:工具包拥有跨调用引导(`tool:bash`、`tool:read` 等);此插件拥有 `harness:identity` 与 `deployment:persona`。 - 变量提供方:agent loop(智能体循环)注册 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)。 - 工具 schema 提供方:`ToolRegistry` 自动将自身注册为工具提供方。 -- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果。 +- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果,之后再实施 complete 段约束。 设计原理:[提示词变量 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)。 @@ -51,7 +51,7 @@ #### 模型看到的内容 -默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅为拥有完整兼容 persona 的部署省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。 +默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。`system-prompt/assemble` waterfall 决定交付的提示词与工具 schema,除非一个有效段声明自身为 complete;此时,该确切段落会成为完整的系统提示词,而 waterfall 得到的上下文、工具和变量保持不变。 ##### Harness 身份 diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 23b5936e08..22bcd1aa9d 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -21,7 +21,9 @@ declare module 'cordis' { * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners * receive only that scope's assemblies. The returned value is authoritative. * A supplied signal controls only this explicit assembly request and must not - * be retained to control later turns. + * be retained to control later turns. A registered complete section is + * restored after this waterfall, so listeners cannot add to or replace + * that scope's system prompt. * @param assembly - the mutable assembly built from registered providers. * @param context - the caller's per-assembly context. * @mode waterfall @@ -63,6 +65,13 @@ export interface PromptSection { * interpolated later, by {@link renderPrompt}. */ readonly text: string | ((context: AssembleContext) => string) + /** + * Treat this contribution as the complete system prompt. Assembly still + * runs the cooperative waterfall so tools, contexts, and variables can be + * resolved, then restores this exact section as the sole prompt section. + * More than one effective complete section makes assembly fail. + */ + readonly complete?: boolean } /** Dynamic model context materialized as a durable user-role snapshot. */ @@ -428,9 +437,11 @@ export class SystemPrompt extends Service { /** * Assemble global and scoped providers, detach tool parameters, apply * canonical ordering, then run the assembly waterfall. Scoped sections and - * variables shadow globals; the returned waterfall value is authoritative. + * variables shadow globals. The returned waterfall value is authoritative + * except that an effective complete section is restored afterwards as the + * sole prompt section. * @param context - the optional scope and plugin-defined assembly fields. - * @returns the authoritative post-waterfall assembly. + * @returns the post-waterfall assembly with any complete prompt enforced. */ // Keep configuration failures on the declared asynchronous error path. async assemble(context: AssembleContext = {}): Promise<PromptAssembly> { @@ -467,13 +478,25 @@ export class SystemPrompt extends Service { collected.push(...schemas) for (const name of acceptedKnownNames) knownNames.add(name) } + const completeSections = [...sectionByName.values()].filter(section => section.complete === true) + if (completeSections.length > 1) { + throw new Error(`multiple complete prompt sections are active: ${completeSections.map(section => JSON.stringify(section.name)).join(', ')}`) + } + const sections = [...sectionByName.values()] + .sort((a, b) => a.order - b.order) + .map(section => ({ + name: section.name, + text: typeof section.text === 'function' ? section.text(context) : section.text, + })) + const completeName = completeSections[0]?.name + let completeSection: AssembledSection | undefined + if (completeName !== undefined) { + const assembled = sections.find(section => section.name === completeName) + if (assembled === undefined) throw new Error(`complete prompt section ${JSON.stringify(completeName)} did not assemble`) + completeSection = { ...assembled } + } const assembly: PromptAssembly = { - sections: [...sectionByName.values()] - .sort((a, b) => a.order - b.order) - .map(section => ({ - name: section.name, - text: typeof section.text === 'function' ? section.text(context) : section.text, - })), + sections, contexts: [...contextByName.values()] .sort((a, b) => a.order - b.order) .map(entry => ({ @@ -483,10 +506,12 @@ export class SystemPrompt extends Service { tools: orderTools(collected, this.toolOrder, knownNames), variables, } - return this.ctx.waterfall( + const transformed = await this.ctx.waterfall( scopeTarget(this, scope), 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly), ) + if (completeSection === undefined) return transformed + return { ...transformed, sections: [completeSection] } } } diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 834c17c341..b51eaf8fc9 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -264,6 +264,34 @@ describe('SystemPrompt', () => { expect(assembly.sections).toHaveLength(0) }) + it('restores one complete section after the assembly waterfall', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'complete', order: 10, text: 'Exact prompt.', complete: true }) + ctx.systemPrompt.section({ name: 'extra', order: 20, text: 'extra' }) + ctx.on('system-prompt/assemble', async (assembly, _context, next) => { + const complete = assembly.sections.find(section => section.name === 'complete') + if (complete === undefined) throw new Error('complete section missing before waterfall') + complete.text = 'mutated' + assembly.sections.push({ name: 'late', text: 'late' }) + return next() + }, { prepend: true }) + + expect((await ctx.systemPrompt.assemble()).sections).toEqual([ + { name: 'complete', text: 'Exact prompt.' }, + ]) + }) + + it('rejects multiple effective complete sections', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'first', order: 10, text: 'first', complete: true }) + ctx.systemPrompt.section({ name: 'second', order: 20, text: 'second', complete: true }) + + await expect(ctx.systemPrompt.assemble()) + .rejects.toThrow('multiple complete prompt sections are active: "first", "second"') + }) + it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index eb707c01f6..00102e9e51 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -331,11 +331,11 @@ describe('agentPreset.select', () => { }) it('records the switch in the log, and the list reads it back', async () => { - const { api, ctx } = await harness(['standard', 'core-web']) + const { api, ctx } = await harness(['standard', 'minimal']) await api.sessions.create(request({ sessionId: SessionId('sel-log'), agentPreset: 'standard' })) await api.agentPresets.select( - request({ sessionId: SessionId('sel-log'), agentPreset: 'core-web' })) + request({ sessionId: SessionId('sel-log'), agentPreset: 'minimal' })) // The header is written once at creation, so the switch lives in the log — // this is what a restart replays and what every projection resolves from. @@ -343,11 +343,11 @@ describe('agentPreset.select', () => { const session = ctx.sessions.get(SessionId('sel-log')) if (session === undefined) throw new Error('unreachable') expect(session.header.agentPreset).toBe('standard') - expect(resolveSessionPreset(session)).toBe('core-web') + expect(resolveSessionPreset(session)).toBe('minimal') const listed = await api.sessions.list(request({})) if (!listed.result.ok) throw new Error('unreachable') expect(listed.result.value.items.find(item => item.sessionId === 'sel-log')?.agentPreset) - .toBe('core-web') + .toBe('minimal') }) it('frames the committed switch so clients can drop that session\'s catalogs', async () => { @@ -382,14 +382,14 @@ describe('agentPreset.select', () => { }) it('serializes two concurrent selects on one session', async () => { - const { api, ctx } = await harness(['standard', 'core-web']) + const { api, ctx } = await harness(['standard', 'minimal']) await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })) // Both pass the blank check; unserialized, the second unmount finds no // record because the first already removed it, and two compositions end up // in one agent layer. The client's busy flag is not enforcement. const [first, second] = await Promise.all([ - api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'core-web' })), + api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'minimal' })), api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })), ]) @@ -612,7 +612,7 @@ describe('skills over the layered host registry', () => { }) it('resolves a cold session to its recorded preset standing key', async () => { - const { api, ctx } = await harness(['standard', 'core-web']) + const { api, ctx } = await harness(['standard', 'minimal']) const seen: unknown[] = [] ctx.provide('skills', { list: (options: { scope?: unknown }) => { @@ -620,12 +620,12 @@ describe('skills over the layered host registry', () => { return Promise.resolve([]) }, } as never) - ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'core-web' } }) + ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'minimal' } }) const response = await api.skills.list(request({ sessionId: SessionId('h2') })) expect(response.result).toMatchObject({ ok: true, value: { skills: [] } }) - expect(seen).toEqual([standingKeys.get('core-web')]) + expect(seen).toEqual([standingKeys.get('minimal')]) }) it('serves the global view when the roster no longer supplies the recorded preset', async () => { @@ -648,8 +648,8 @@ describe('skills over the layered host registry', () => { describe('session.history presenter scope', () => { it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => { - const { api } = await harness(['standard', 'core-web']) - await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'core-web' })) + const { api } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'minimal' })) // Cold: creation registered a live agent in this harness, so simulate the // cold path by asking for a session only persistence knows... the harness // has no persistence, so read the live one and assert no roster query. diff --git a/packages/preset/persona/README.i18n.yaml b/packages/preset/persona/README.i18n.yaml index c4573b49f8..f40850a6c9 100644 --- a/packages/preset/persona/README.i18n.yaml +++ b/packages/preset/persona/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/persona/README.md -README.md: 789776b32d907f7d217accccbca5508f88de0ed1 -README.zh.md: 4e28d75bbd4fd22b77a0fa3b18c5f19df08588d8 +README.md: 742141e65fa8d50b89e6b74e6d21aa8c5bfe98cd +README.zh.md: add106adb5b81e45d8c6929a9a0f98b5c0072a01 diff --git a/packages/preset/persona/README.md b/packages/preset/persona/README.md index 789776b32d..742141e65f 100644 --- a/packages/preset/persona/README.md +++ b/packages/preset/persona/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The agent persona as a composable row. One config field, one prompt section. +The agent persona as a composable row. It can either shadow the deployment persona or own the complete system prompt. [`dsh-system-prompt`](../../core/system-prompt/README.md) owns the deployment persona as its own config and registers that section unconditionally, so a process has exactly one. An [agent preset](../agent-presets/README.md) cannot mount the prompt registry itself — without a row of its own, a preset could change an agent's tools but never its identity. This package is that row. @@ -15,8 +15,9 @@ Mounting this row outside an agent scope collides with the registry's own `deplo | Field | Default | Meaning | |---|---|---| | `text` | required | Persona prose rendered as the `deployment:persona` section | +| `complete` | `false` | Restore this persona after assembly as the only system-prompt section | -`text` is a template, like any prompt section: complete `{{…}}` groups resolve strictly against registered prompt variables when the prompt renders, not when it assembles. Empty text still occupies the slot, so it shadows the deployment persona away entirely and then disappears at render. +`text` is a template, like any prompt section: complete `{{…}}` groups resolve strictly against registered prompt variables when the prompt renders, not when it assembles. Empty text still occupies the slot, so it shadows the deployment persona away entirely and then disappears at render. With `complete: true`, assembly still resolves contexts, tools, variables, and cooperative listeners, then the prompt registry restores this exact persona as the sole section; no identity, tool guidance, or listener can append prompt text. ## Model Experience @@ -24,11 +25,11 @@ Mounting this row outside an agent scope collides with the registry's own `deplo #### What the model sees -The `deployment:persona` section at order 0, immediately after the harness identity opener, carrying exactly this row's configured `text` with prompt variables resolved. For an agent whose preset mounts this row, it replaces whatever persona the deployment configured. +The `deployment:persona` section at order 0, immediately after the harness identity opener, carrying exactly this row's configured `text` with prompt variables resolved. For an agent whose preset mounts this row, it replaces whatever persona the deployment configured. In complete mode, the model sees only this rendered section as its system prompt. #### Token effect -Fixed for a given preset: the persona's own tokens on every request that agent makes, and none for any other agent. Empty text contributes nothing. +Fixed for a given preset: the persona's own tokens on every request that agent makes, and none for any other agent. Empty text contributes nothing. Complete mode removes every other system-prompt token for that agent. #### KV Cache effect diff --git a/packages/preset/persona/README.zh.md b/packages/preset/persona/README.zh.md index 4e28d75bbd..add106adb5 100644 --- a/packages/preset/persona/README.zh.md +++ b/packages/preset/persona/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -把 agent(智能体)人设做成一个可组装的行:一个配置字段,一个提示词段落。 +把 agent(智能体)人设做成一个可组装的行:它既可以遮蔽部署级人设,也可以拥有完整系统提示词。 [`dsh-system-prompt`](../../core/system-prompt/README.md) 以自身配置持有部署级人设,并且无条件注册该段落,因此一个进程只有一份。[agent preset](../agent-presets/README.md) 无法自行挂载提示词注册表——若没有属于自己的行,preset 能改变 agent 的工具,却永远改不了它的身份。本包就是那一行。 @@ -15,8 +15,9 @@ | 字段 | 默认值 | 含义 | |---|---|---| | `text` | 必填 | 作为 `deployment:persona` 段落渲染的人设文本 | +| `complete` | `false` | 组装后将此人设恢复为唯一的系统提示词段落 | -`text` 与任何提示词段落一样是模板:完整的 `{{…}}` 组在提示词**渲染**时(而非组装时)严格解析为已注册的提示词变量。空文本同样占据该槽位,因此会把部署级人设整个遮蔽掉,然后在渲染时消失。 +`text` 与任何提示词段落一样是模板:完整的 `{{…}}` 组在提示词**渲染**时(而非组装时)严格解析为已注册的提示词变量。空文本同样占据该槽位,因此会把部署级人设整个遮蔽掉,然后在渲染时消失。启用 `complete: true` 时,组装仍会解析上下文、工具、变量和协作式监听器,之后提示词注册表将这份确切人设恢复为唯一段落;身份、工具引导或监听器都无法追加提示词文本。 ## Model Experience @@ -24,11 +25,11 @@ #### What the model sees -位于 order 0 的 `deployment:persona` 段落,紧随 harness 身份开场白之后,携带本行配置的 `text`,其中的提示词变量已解析。对于其 preset 挂载了本行的 agent,它会替换部署所配置的任何人设。 +位于 order 0 的 `deployment:persona` 段落,紧随 harness 身份开场白之后,携带本行配置的 `text`,其中的提示词变量已解析。对于其 preset 挂载了本行的 agent,它会替换部署所配置的任何人设。在完整模式下,模型只会看到这个渲染后的段落作为系统提示词。 #### Token effect -对给定 preset 而言是固定的:该 agent 的每次请求都携带人设自身的 token,其他 agent 一个都不带。空文本不贡献任何 token。 +对给定 preset 而言是固定的:该 agent 的每次请求都携带人设自身的 token,其他 agent 一个都不带。空文本不贡献任何 token。完整模式会移除该 agent 的其他所有系统提示词 token。 #### KV Cache effect diff --git a/packages/preset/persona/src/index.ts b/packages/preset/persona/src/index.ts index ec56bcc780..a76238033d 100644 --- a/packages/preset/persona/src/index.ts +++ b/packages/preset/persona/src/index.ts @@ -38,23 +38,27 @@ export interface Config { * variables. Empty text drops the section at render, matching the registry. */ text: string + /** Make this persona the complete system prompt, suppressing every other section. */ + complete?: boolean } /** Runtime schema for the persona row. */ export const Config: z<Config> = z.object({ text: z.string().required(), + complete: z.boolean().default(false), }) /** * Register the persona section for the mounting context's scope. * @param ctx - an agent scope context; an unscoped context collides with the * prompt registry's own persona registration and rejects. - * @param config - the persona text. + * @param config - the persona text and complete-prompt policy. */ export function apply(ctx: Context, config: Config): void { ctx.effect(() => ctx.systemPrompt.section({ name: PERSONA_SECTION, order: PERSONA_ORDER, text: config.text, + complete: config.complete ?? false, }), 'persona.section()') } diff --git a/packages/preset/persona/src/invariant.ts b/packages/preset/persona/src/invariant.ts index 5f9068fe24..be85fd285d 100644 --- a/packages/preset/persona/src/invariant.ts +++ b/packages/preset/persona/src/invariant.ts @@ -16,7 +16,8 @@ export const inject = ['invariants'] /** * No runtime invariant: this row owns no event stream or mutable runtime data — it registers one - * prompt section and the prompt registry owns section identity, shadowing, and disposal. + * prompt section and the prompt registry owns identity, complete-prompt enforcement, shadowing, + * and disposal. */ const install: InvariantInstaller = () => {} diff --git a/packages/preset/persona/tests/persona.spec.ts b/packages/preset/persona/tests/persona.spec.ts index bb7555df7c..7c246e75a0 100644 --- a/packages/preset/persona/tests/persona.spec.ts +++ b/packages/preset/persona/tests/persona.spec.ts @@ -85,4 +85,21 @@ describe('the persona row', () => { expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))) .toContain('You run on deepseek-v4-pro.') }) + + it('makes a complete persona the exact prompt after every other contribution', async () => { + const ctx = await harness('deployment identity') + const key: ScopeKey = { agent: 'a1' } + const scope = createScope(ctx, key) + ctx.systemPrompt.section({ name: 'global:extra', order: 100, text: 'global guidance' }) + + await scope.ctx.plugin(Persona, { text: 'Only this.', complete: true }) + scope.ctx.on('system-prompt/assemble', async (assembly, _context, next) => { + assembly.sections.push({ name: 'late:extra', text: 'late guidance' }) + return next() + }, { prepend: true }) + + const assembly = await ctx.systemPrompt.assemble({ scope: key }) + expect(assembly.sections).toEqual([{ name: PERSONA_SECTION, text: 'Only this.' }]) + expect(renderPrompt(assembly)).toBe('Only this.') + }) }) diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 9e63aa3ea3..2d575896d0 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1106,7 +1106,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async assemble(context: AssembleContext = {}): Promise<PromptAssembly>', - jsDoc: '/**\n * Assemble global and scoped providers, detach tool parameters, apply\n * canonical ordering, then run the assembly waterfall. Scoped sections and\n * variables shadow globals; the returned waterfall value is authoritative.\n * @param context - the optional scope and plugin-defined assembly fields.\n * @returns the authoritative post-waterfall assembly.\n */', + jsDoc: '/**\n * Assemble global and scoped providers, detach tool parameters, apply\n * canonical ordering, then run the assembly waterfall. Scoped sections and\n * variables shadow globals. The returned waterfall value is authoritative\n * except that an effective complete section is restored afterwards as the\n * sole prompt section.\n * @param context - the optional scope and plugin-defined assembly fields.\n * @returns the post-waterfall assembly with any complete prompt enforced.\n */', }, ], }, @@ -1602,7 +1602,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'system-prompt/assemble', mode: 'waterfall', signature: '\'system-prompt/assemble\'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>', - jsDoc: '/**\n * Expert waterfall over the assembled sections, contexts, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */', + jsDoc: '/**\n * Expert waterfall over the assembled sections, contexts, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns. A registered complete section is\n * restored after this waterfall, so listeners cannot add to or replace\n * that scope\'s system prompt.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */', summary: 'Expert waterfall over the assembled sections, contexts, tools, and variables.', }, { @@ -2413,7 +2413,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptSection', - declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}', + declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly complete?: boolean;\n}', }, { name: 'ProviderRequestId', diff --git a/tsconfig.host.json b/tsconfig.host.json index d9bf1c29e4..027e00c7e6 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -13,7 +13,7 @@ "apps/web/tests/declared-reasoning.e2e.ts", "apps/web/tests/support.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", - "apps/web/tests/core-web-profile.snapshot.ts", + "apps/web/tests/minimal-preset.snapshot.ts", "apps/web/tests/live-interactions.e2e.ts", "apps/web/tests/question-composer.e2e.ts", "apps/web/tests/approval-composer.e2e.ts", From ef7195a00a34747017b7eb1587fdbd845f45da6b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:12:36 +0800 Subject: [PATCH 102/229] fix(skill): enforce manual invocation policy --- ...outine-documentation-translation.i18n.yaml | 4 +- ...eight-routine-documentation-translation.md | 6 +- ...ht-routine-documentation-translation.zh.md | 6 +- .agents/skills/dsh-translate-docs/SKILL.md | 1 + docs/AGENTS.md | 2 +- package.json | 1 + scripts/run-gates.ts | 1 + .../request-response.expected.json | 24 +--- .../verify-skill-invocation-metadata.spec.ts | 53 ++++++++ scripts/verify-skill-invocation-metadata.ts | 122 ++++++++++++++++++ 10 files changed, 191 insertions(+), 29 deletions(-) create mode 100644 scripts/verify-skill-invocation-metadata.spec.ts create mode 100644 scripts/verify-skill-invocation-metadata.ts diff --git a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml index d86489d45a..9805a10d2c 100644 --- a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-lightweight-routine-documentation-translation.md -2026-08-08-lightweight-routine-documentation-translation.md: 713c2f14541aff49411b6f7d8b6bf5b4e02fa667 -2026-08-08-lightweight-routine-documentation-translation.zh.md: 7cb13e9fcaa8b8a4d38ab6c0050eec99c1025b46 +2026-08-08-lightweight-routine-documentation-translation.md: ff4d6005588b562018bf1ee40d6dabb5569766f0 +2026-08-08-lightweight-routine-documentation-translation.zh.md: fe809d328e739c2c869d5597cf6786198864bd56 diff --git a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md index 713c2f1454..ff4d600558 100644 --- a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md +++ b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md @@ -10,8 +10,8 @@ Routine bilingual edits automatically selected the full [translation skill](../. ## Decision -- **Routine translation is one shot and one pass.** The active agent loads [terminology.md](../../../../docs/i18n/terminology.md), translates only the changed content directly, preserves reviewed counterpart prose outside the change, and re-records the pair. It does not invoke a translation skill, generate a briefing, start a separate translation-review pass, or delegate translation to a subagent. -- **The extended workflow is manual-only.** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) retains its briefing, delegated prose, whole-document, and scoped-verification paths. Claude Code sees `disable-model-invocation: true` with `user-invocable: true` in `SKILL.md`; Codex sees `policy.allow_implicit_invocation: false` in `agents/openai.yaml`. The repository's `.claude/skills` symlink projects the same skill directory to Claude Code, so both products share one committed workflow while enforcing their own invocation metadata. +- **Routine translation is one shot and one pass.** The active agent loads [terminology.md](../../../../docs/i18n/terminology.md), translates only the changed content directly, moves a terminology annotation when the true first occurrence crosses the edit boundary, otherwise preserves reviewed counterpart prose outside the change, and re-records the pair. It does not invoke a translation skill, generate a briefing, start a separate translation-review pass, or delegate translation to a subagent. +- **The extended workflow is manual-only.** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) retains its briefing, delegated prose, whole-document, and scoped-verification paths. The [Claude Code skill contract](https://code.claude.com/docs/en/skills#control-who-invokes-a-skill) reads `disable-model-invocation: true` with `user-invocable: true` in `SKILL.md`; Codex reads `policy.allow_implicit_invocation: false` in `agents/openai.yaml`. The repository's `.claude/skills` symlink projects the same skill directory to Claude Code, so both products share one committed workflow while enforcing their own invocation metadata. The `doc-sync` skill-invocation-metadata gate keeps those independent policies aligned. - **Automatic workflows do not chain into the manual skill.** Root and documentation instructions own the lightweight default. Documentation, website-sync, prose, and code-review skills link to those instructions or the i18n contracts instead of loading `dsh-translate-docs` from an inferred bilingual change. - **The pairing and review contracts stay intact.** Both language files still update together, untouched counterpart wording remains stable, terminology stays binding, the consistency record is rewritten only after the active agent confirms the pair, and `doc-sync` retains the corpus-wide mechanical checks. Human review still owns semantic translation quality. @@ -27,4 +27,4 @@ Routine bilingual edits automatically selected the full [translation skill](../. - Ordinary development pays for the changed source text, its local counterpart context, and the terminology table rather than the extended workflow's briefing and subagent context. - The active agent owns the final routine translation in the same turn. The lightweight path deliberately gives up the extended workflow's generated alignment, delegated isolation, and separate prose-verification pass. - Explicit users can still invoke the full workflow through `/dsh-translate-docs` in Claude Code or `$dsh-translate-docs` in Codex. -- The Claude Code frontmatter and Codex policy file are separate product contracts and must remain aligned when the skill's invocation policy changes. +- The Claude Code frontmatter and Codex policy file are separate product contracts; `doc-sync` rejects a skill that becomes manual-only on only one product or becomes unavailable to the Claude Code user as well as the model. diff --git a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md index 7cb13e9fca..fe809d328e 100644 --- a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md @@ -10,8 +10,8 @@ Status: implemented ## 决策 -- **日常翻译一次完成,只处理一遍。** 当前 agent(智能体)加载 [terminology.md](../../../../docs/i18n/terminology.md),直接翻译发生改动的内容,保留改动之外已经评审的对侧文件行文,并重新记录配对。它不会调用翻译 skill、生成简报、启动单独的翻译评审轮次,也不会把翻译委派给 subagent。 -- **扩展工作流仅限手动调用。** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 保留简报、行文翻译委派、整篇文档和按范围核验路径。在 `SKILL.md` 中,Claude Code 读取 `disable-model-invocation: true` 和 `user-invocable: true`;在 `agents/openai.yaml` 中,Codex 读取 `policy.allow_implicit_invocation: false`。仓库的 `.claude/skills` 符号链接把同一个 skill 目录映射给 Claude Code,因此两个产品共享同一份提交到仓库的工作流,同时分别执行各自的调用元数据契约。 +- **日常翻译一次完成,只处理一遍。** 当前 agent(智能体)加载 [terminology.md](../../../../docs/i18n/terminology.md),直接翻译发生改动的内容;如果术语的实际首现位置跨过了编辑边界,则移动相应括注,否则保留改动之外已经评审的对侧文件行文;最后重新记录配对。它不会调用翻译 skill、生成简报、启动单独的翻译评审轮次,也不会把翻译委派给 subagent。 +- **扩展工作流仅限手动调用。** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 保留简报、行文翻译委派、整篇文档和按范围核验路径。[Claude Code skill 契约](https://code.claude.com/docs/en/skills#control-who-invokes-a-skill)读取 `SKILL.md` 中的 `disable-model-invocation: true` 和 `user-invocable: true`;Codex 读取 `agents/openai.yaml` 中的 `policy.allow_implicit_invocation: false`。仓库的 `.claude/skills` 符号链接把同一个 skill 目录映射给 Claude Code,因此两个产品共享同一份提交到仓库的工作流,同时分别执行各自的调用元数据契约。`doc-sync` 中的 skill 调用元数据门禁会让这两份独立策略保持一致。 - **自动工作流不会串联调用这项仅限手动调用的 skill。** 轻量默认行为由根级指令和文档指令定义。文档、网站同步、行文和代码评审 skill 会链接这些指令或 i18n 契约,而不会因为推断到双语改动就加载 `dsh-translate-docs`。 - **配对契约与评审契约保持不变。** 两种语言文件仍会一并更新;未触及的对侧文件措辞保持稳定;术语约束仍然有效;只有当前 agent 确认配对后,才会重写一致性记录;`doc-sync`(文档同步门禁)继续执行全语料机械检查。语义层面的翻译质量仍由人工评审负责。 @@ -27,4 +27,4 @@ Status: implemented - 普通开发的成本来自发生改动的源文本、其局部对侧文件上下文和术语表,不再来自扩展工作流的简报与 subagent 上下文。 - 当前 agent 在同一轮次内对日常翻译的最终结果负责。轻量路径有意放弃扩展工作流提供的自动生成对齐信息、委派所提供的隔离,以及单独的行文核验轮次。 - 用户仍可在 Claude Code 中通过 `/dsh-translate-docs`,或在 Codex 中通过 `$dsh-translate-docs` 显式调用完整工作流。 -- Claude Code frontmatter 与 Codex 策略文件是彼此独立的产品契约;skill 调用策略变更时,两者必须保持一致。 +- Claude Code frontmatter 与 Codex 策略文件是彼此独立的产品契约;如果某项 skill 仅在一个产品中变为手动调用,或者在 Claude Code 中对模型和用户都不可用,`doc-sync` 会拒绝该状态。 diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index 5057d8b760..332c55920b 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -49,6 +49,7 @@ When translations need to be written from scratch, the orchestrating agent does - **Pass 1 — write, don't transpose.** Read a semantic unit, then restate it as a native technical author in the nearest [style sample's](../../../docs/i18n/style-samples.md) register. Preserve the required frame without forcing sentence-by-sentence correspondence. - **Pass 2 — verify against the source, clause by clause.** Fidelity is checked here, not written in: confirm nothing was added or dropped, every term follows the table, and each code span survived verbatim. Fix by rewriting the sentence natively, not by patching words into it. +- **Read the completed counterpart alone.** After the source comparison, read the translated file without the source beside it and rewrite phrasing whose awkwardness only becomes visible in isolation. - Write only the final text to the file, never drafts or notes. - Every term in [terminology.md](../../../docs/i18n/terminology.md) renders exactly as specified. For a Chinese target, use the Chinese and first-occurrence columns; an unlisted term needs a citable Chinese OSS/vendor precedent or stays English under 「待定术语」. For an English target, use the English column and an established English technical term; preserve an ambiguous source term with a short gloss and list it as pending. Never invent a rendering inline. - Code blocks are byte-identical across the pair, comments included. Relative links keep their `.md` targets; only the switcher line links `.zh.md`. diff --git a/docs/AGENTS.md b/docs/AGENTS.md index bc2081da1d..96c9b36352 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -40,7 +40,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The owning [subsystems page](subsystems/README.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types; a type is documented on its declaring package group's page ([page scoping](../.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md)). -- **Bilingual pairs update together**: load [terminology](i18n/terminology.md), translate changed content one-shot and one-pass in the active agent, preserve untouched counterpart prose, and re-record. Only explicit user invocation may run `dsh-translate-docs` ([contract](i18n/README.md)). +- **Pairs update together**: [Terminology-guided](i18n/terminology.md), single-pass active-agent work repositions first-use annotations, preserves untouched prose, and re-records; `dsh-translate-docs` remains user-invoked ([contract](i18n/README.md)). - **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, failure, timing, ownership, modality, exceptions, consequences, and non-obvious orientation; delete narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link its rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for details. - Write directly: name actors and facts ([decision](../.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)). Reserve `seam` for the defined capability. Name the exact check, type, API, operation, or behavior instead of metaphorical "gate", "vocabulary", or "surface". diff --git a/package.json b/package.json index d417354739..2830640c26 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "verify-agent-note-format": "tsx scripts/verify-agent-note-format.ts", "verify-archived-agent-notes": "tsx scripts/verify-archived-agent-notes.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", + "verify-skill-invocation-metadata": "tsx scripts/verify-skill-invocation-metadata.ts", "verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts", "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "resolve-translation-pairing-conflicts": "tsx scripts/merge-translation-pairing.ts --resolve", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index e8055db866..3b88da217f 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -599,6 +599,7 @@ function docSyncLeafGates(options: { pnpmScript('agent-note-format', 'verify-agent-note-format', { label: 'agent note format' }), pnpmScript('archived-agent-notes', 'verify-archived-agent-notes', { label: 'archived agent notes' }), pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }), + pnpmScript('skill-invocation-metadata', 'verify-skill-invocation-metadata', { label: 'skill invocation metadata' }), pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }), pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }), pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }), diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index ab862dba34..63050b2079 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -24,19 +24,11 @@ }, { "role": "user", -<<<<<<< HEAD - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief <pair>` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing <pair...>` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write <pair>`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" + "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief <pair>` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing <pair...>` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write <pair>`**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief <pair>` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing <pair...>` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write <pair>`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" -======= - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, enforcement gate, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief <pair>` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing <pair...>` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write <pair>`**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files whose generators emit English only; a hand-written translation would go stale on regeneration.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" - }, - { - "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对契约、强制门禁、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n## 配对契约\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief <pair>` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n- **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing <pair...>` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write <pair>` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分契约由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件,其生成器只输出英文;手写译文会在重新生成时变得陈旧。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" ->>>>>>> 45777c7624 (test(snapshot): refresh translation prompt fixture) + "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief <pair>` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing <pair...>` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write <pair>` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" }, { "role": "user", @@ -48,19 +40,11 @@ }, { "role": "user", -<<<<<<< HEAD - "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write <pair>`, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, the Chinese side and every authored English source carry their switchers while listed generated English sources are exempt, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — `.zh.md` files would carry an HTML comment recording the English source's blob hash, and translation would flow EN → ZH only. Rejected: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus a committed agent skill in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated English documents remain derived from source and freshness-gated by their owning generators. A generated page with a reviewed Chinese counterpart participates in the three-file pairing workflow, with one structural exception: the generated English source has no language switcher because adding one would make the generator stale, while the Chinese counterpart links back to it. Generated pages without a reviewed counterpart remain explicit exclusions and use an English website projection.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.\n" + "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write <pair>`, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, the Chinese side and every authored English source carry their switchers while listed generated English sources are exempt, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** Routine changes use the direct one-pass path owned by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). The [extended translation skill](../../../skills/dsh-translate-docs/SKILL.md) retains delegated translation and the other heavier mechanisms for explicit user invocation; both paths defer to the documentation contracts as their sources of truth.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — `.zh.md` files would carry an HTML comment recording the English source's blob hash, and translation would flow EN → ZH only. Rejected: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus an agent-run workflow in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated English documents remain derived from source and freshness-gated by their owning generators. A generated page with a reviewed Chinese counterpart participates in the three-file pairing workflow, with one structural exception: the generated English source has no language switcher because adding one would make the generator stale, while the Chinese counterpart links back to it. Generated pages without a reviewed counterpart remain explicit exclusions and use an English website projection.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.\n" }, { "role": "assistant", - "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write <pair>`,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、中文侧和所有普通撰写的英文源都带切换行而清单内的生成英文源除外、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。\n\n## 验证\n\n验证约定分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。否决:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个仓库内置的 agent skill 替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成的英文文档仍由源码派生,并由各自的生成器实施新鲜度门禁。有经评审中文对侧的生成页面遵循三文件配对工作流,但有一项结构例外:生成的英文源文件不含语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。没有经评审对侧的生成页面保留为显式排除项,并在网站上投影英文。\n- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" -======= - "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write <pair>`, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** Routine changes use the direct one-pass path owned by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). The [extended translation skill](../../../skills/dsh-translate-docs/SKILL.md) retains delegated translation and the other heavier mechanisms for explicit user invocation; both paths defer to the documentation contracts as their sources of truth.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — the design first proposed for this Agent Note: `.zh.md` files carried an HTML comment recording the English source's blob hash, and translation flowed EN → ZH only. Revised in review: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus an agent-run workflow in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.\n" - }, - { - "role": "assistant", - "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write <pair>`,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 常规改动采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.md)确立的直接单遍路径。[扩展翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)保留委派翻译和其他较重机制,供用户显式调用;两条路径均以文档契约为真源。\n\n## 验证\n\n验证契约分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:本 Agent Note 最初提出的设计:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。评审中修订:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个由 agent 运行的工作流替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" ->>>>>>> 45777c7624 (test(snapshot): refresh translation prompt fixture) + "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write <pair>`,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、中文侧和所有普通撰写的英文源都带切换行而清单内的生成英文源除外、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 常规改动采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.md)确立的直接单遍路径。[扩展翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)保留委派翻译和其他较重机制,供用户显式调用;两条路径均以文档契约为真源。\n\n## 验证\n\n验证约定分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。否决:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个由 agent 运行的工作流替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成的英文文档仍由源码派生,并由各自的生成器实施新鲜度门禁。有经评审中文对侧的生成页面遵循三文件配对工作流,但有一项结构例外:生成的英文源文件不含语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。没有经评审对侧的生成页面保留为显式排除项,并在网站上投影英文。\n- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" }, { "role": "user", diff --git a/scripts/verify-skill-invocation-metadata.spec.ts b/scripts/verify-skill-invocation-metadata.spec.ts new file mode 100644 index 0000000000..88dbfed84e --- /dev/null +++ b/scripts/verify-skill-invocation-metadata.spec.ts @@ -0,0 +1,53 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { collectSkillInvocationMetadataViolations } from './verify-skill-invocation-metadata.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function fixtureRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-skill-invocation-metadata-')) + roots.push(root) + return root +} + +function writeSkill(root: string, name: string, frontmatter: string, policy = ''): void { + const directory = join(root, '.agents/skills', name) + mkdirSync(join(directory, 'agents'), { recursive: true }) + writeFileSync(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: Test skill\n${frontmatter}---\n\nTest.\n`) + writeFileSync( + join(directory, 'agents/openai.yaml'), + `interface:\n display_name: "Test"\n${policy}`, + ) +} + +describe('cross-product skill invocation metadata gate', () => { + it('accepts aligned default and manual-only policies', () => { + const root = fixtureRoot() + writeSkill(root, 'default-skill', '') + writeSkill( + root, + 'manual-skill', + 'disable-model-invocation: true\nuser-invocable: true\n', + 'policy:\n allow_implicit_invocation: false\n', + ) + + expect(collectSkillInvocationMetadataViolations(root)).toEqual([]) + }) + + it('rejects either direction of a manual-only policy mismatch', () => { + const root = fixtureRoot() + writeSkill(root, 'claude-only', 'disable-model-invocation: true\n') + writeSkill(root, 'codex-only', '', 'policy:\n allow_implicit_invocation: false\n') + + expect(collectSkillInvocationMetadataViolations(root)).toEqual([ + '.agents/skills/claude-only: Claude Code manual-only=true but Codex manual-only=false', + '.agents/skills/codex-only: Claude Code manual-only=false but Codex manual-only=true', + ]) + }) +}) diff --git a/scripts/verify-skill-invocation-metadata.ts b/scripts/verify-skill-invocation-metadata.ts new file mode 100644 index 0000000000..f7e7712c6e --- /dev/null +++ b/scripts/verify-skill-invocation-metadata.ts @@ -0,0 +1,122 @@ +/** + * Keep Claude Code and Codex invocation metadata aligned for repository skills. + * @module scripts/verify-skill-invocation-metadata + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { load } from 'js-yaml' + +const ROOT = resolve(import.meta.dirname, '..') + +/** Return an object-shaped YAML value, or undefined for every other shape. */ +function asRecord(value: unknown): Record<string, unknown> | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record<string, unknown> + : undefined +} + +/** Parse a skill's YAML frontmatter as an object. */ +function parseSkillFrontmatter(source: string): Record<string, unknown> { + const lines = source.split('\n') + if (lines[0] !== '---') throw new Error('SKILL.md must start with YAML frontmatter') + const end = lines.indexOf('---', 1) + if (end < 0) throw new Error('SKILL.md frontmatter is not closed') + const metadata = asRecord(load(lines.slice(1, end).join('\n'))) + if (metadata === undefined) throw new Error('SKILL.md frontmatter must be a YAML object') + return metadata +} + +/** Find repository skill directories that carry Codex product metadata. */ +function skillDirectories(root: string): string[] { + const skillsRoot = resolve(root, '.agents/skills') + if (!existsSync(skillsRoot)) return [] + return readdirSync(skillsRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && existsSync(resolve(skillsRoot, entry.name, 'agents/openai.yaml'))) + .map(entry => entry.name) + .sort() +} + +/** + * Report cross-product invocation-policy mismatches for repository skills. + * @param root - Repository root containing `.agents/skills`. + * @returns diagnostics for malformed metadata or policies that expose a skill differently. + */ +export function collectSkillInvocationMetadataViolations(root: string): string[] { + const violations: string[] = [] + + for (const skill of skillDirectories(root)) { + const relativeRoot = `.agents/skills/${skill}` + const skillFile = resolve(root, relativeRoot, 'SKILL.md') + const openaiFile = resolve(root, relativeRoot, 'agents/openai.yaml') + if (!existsSync(skillFile)) { + violations.push(`${relativeRoot}: agents/openai.yaml has no sibling SKILL.md`) + continue + } + + let frontmatter: Record<string, unknown> + let openai: Record<string, unknown> + try { + frontmatter = parseSkillFrontmatter(readFileSync(skillFile, 'utf8')) + } + catch (error) { + violations.push(`${relativeRoot}/SKILL.md: ${error instanceof Error ? error.message : String(error)}`) + continue + } + try { + const parsed = asRecord(load(readFileSync(openaiFile, 'utf8'))) + if (parsed === undefined) throw new Error('agents/openai.yaml must be a YAML object') + openai = parsed + } + catch (error) { + violations.push(`${relativeRoot}/agents/openai.yaml: ${error instanceof Error ? error.message : String(error)}`) + continue + } + + const disableModelInvocation = frontmatter['disable-model-invocation'] + if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') { + violations.push(`${relativeRoot}/SKILL.md: disable-model-invocation must be a boolean`) + continue + } + const userInvocable = frontmatter['user-invocable'] + if (userInvocable !== undefined && typeof userInvocable !== 'boolean') { + violations.push(`${relativeRoot}/SKILL.md: user-invocable must be a boolean`) + continue + } + + const policy = asRecord(openai.policy) + const allowImplicitInvocation = policy?.allow_implicit_invocation + if (allowImplicitInvocation !== undefined && typeof allowImplicitInvocation !== 'boolean') { + violations.push(`${relativeRoot}/agents/openai.yaml: policy.allow_implicit_invocation must be a boolean`) + continue + } + + const claudeManualOnly = disableModelInvocation === true + const codexManualOnly = allowImplicitInvocation === false + if (claudeManualOnly !== codexManualOnly) { + violations.push( + `${relativeRoot}: Claude Code manual-only=${String(claudeManualOnly)}` + + ` but Codex manual-only=${String(codexManualOnly)}`, + ) + } + if (claudeManualOnly && userInvocable === false) { + violations.push(`${relativeRoot}/SKILL.md: a manual-only skill must remain user-invocable`) + } + } + + return violations +} + +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + const skills = skillDirectories(ROOT) + const violations = collectSkillInvocationMetadataViolations(ROOT) + if (violations.length > 0) { + process.stderr.write('verify-skill-invocation-metadata: violations found:\n') + for (const violation of violations) process.stderr.write(` ${violation}\n`) + process.exit(1) + } + + process.stdout.write( + `verify-skill-invocation-metadata: ${String(skills.length)} cross-product skill policy pair(s) aligned.\n`, + ) +} From 59a2e4d825226acc254dc37545835f2e466d0220 Mon Sep 17 00:00:00 2001 From: Huanqi Cao <caohuanqi@deepseek.com> Date: Mon, 10 Aug 2026 19:07:35 +0800 Subject: [PATCH 103/229] fix(ci): restore the native Windows coverage denominator to green The windows-native job has been red since #1990 put the sandbox-windows-acl sources into the Windows 100%-per-file denominator without tests carrying them, and #1543 dropped the authoring.ts V8 ignore for the POSIX-only owner-execute branch. Non-blocking at merge time, the red state has propagated to every later pull request. Cover every in-process ACL-sandbox failure branch with stub-based failure-path suites (ffi/acl/token/spawn/index), following the package's existing failure-paths pattern; the package now measures 100% per file under the Windows denominator. Exclude only the runner entry from the win32 denominator: it executes exclusively as a spawned child outside the instrumented run, and its behavior is pinned end-to-end by the runner suite. Restore the authoring.ts narrow V8 ignore and add one for the dispose token guard whose absent-token arm is lifecycle-unreachable. Update the dual-lane Agent Note with the denominator composition. --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 2 +- .../preset/agent-presets/src/authoring.ts | 1 + .../sandbox/sandbox-windows-acl/src/ffi.ts | 2 + .../sandbox/sandbox-windows-acl/src/index.ts | 2 + .../tests/acl-failure-paths.spec.ts | 456 ++++++++++++++++++ .../tests/failure-paths.spec.ts | 318 +++++++++++- .../sandbox-windows-acl/tests/ffi.spec.ts | 190 ++++++++ .../tests/index-failure-paths.spec.ts | 388 +++++++++++++++ .../tests/token-failure-paths.spec.ts | 436 +++++++++++++++++ vitest.config.ts | 10 + 12 files changed, 1806 insertions(+), 5 deletions(-) create mode 100644 packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 07eb13b5cd..d6e9a87840 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 6a62fddb79670c3ab4cc0446796dbffd7130aed9 -2026-08-08-native-windows-pull-request-ci.zh.md: 990b8ed1434934337b8ff20c5f3be2c03cd9c61b +2026-08-08-native-windows-pull-request-ci.md: 1c6a1c4dcf50ac6fc5d30ea5904fb81249e55dfe +2026-08-08-native-windows-pull-request-ci.zh.md: 4342362815ecf738a4730dc1417c1be0eaddf3af diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 6a62fddb79..1c6a1c4dcf 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -18,7 +18,7 @@ Every pull request also starts an ordinary independent `windows-native` job name The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage. -The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources remain in the denominator; only intrinsically peer-platform source arms use narrow annotated V8 ignores, with their behavior tests retained on the owning platform. +The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four, three, and two concurrent instrumented workers. The selected budget therefore reduces that fan-out to one while retaining the exempt-heavy suite as a second concurrent coverage worker and preserving two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 990b8ed143..4342362815 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -18,7 +18,7 @@ Status: implemented 原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。 -16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码继续计入分母;只有本质上属于另一平台的源码分支使用窄范围且带注释的 V8 ignore,其行为测试仍保留在所属平台。 +16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个、3 个和 2 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 1,同时保留免覆盖率项较多的套件作为第二个并发覆盖率工作线程,并继续让两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 diff --git a/packages/preset/agent-presets/src/authoring.ts b/packages/preset/agent-presets/src/authoring.ts index 5ac4e55874..0f8788ee9b 100644 --- a/packages/preset/agent-presets/src/authoring.ts +++ b/packages/preset/agent-presets/src/authoring.ts @@ -105,6 +105,7 @@ async function tightenModes(dir: string): Promise<void> { if (entry.isDirectory()) { await tightenModes(target) } else { + /* v8 ignore next -- Windows exposes no POSIX owner-execute bit; the POSIX lane covers both file modes. */ await chmod(target, ((await stat(target)).mode & 0o100) === 0 ? 0o600 : 0o700) } } diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 99b3cfaff3..698f0dc2ee 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -168,12 +168,14 @@ export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', { dwThreadId: 'uint32', }) +/* v8 ignore start -- layout-mismatch guards fire only on ABI breakage; verify/abi-probe.cpp pins both sizes. */ if (STARTUPINFOW.size !== abi.STARTUPINFOW_SIZE) { throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, header probe says ${abi.STARTUPINFOW_SIZE}`) } if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) { throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, header probe says ${abi.PROCESS_INFORMATION_SIZE}`) } +/* v8 ignore stop */ /** * Allocate one pointer-sized slot (for `T **` out-parameters). diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index efa966a441..4878d2a183 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -360,6 +360,8 @@ export class AclSandbox { } } const token = this.token + /* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always + has its token; the guard mirrors the write-SID guard's defensive shape. */ if (token !== undefined) { try { if (api.closeHandle(token) === 0) throwLastError(api, 'CloseHandle', 'restricted token') diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts new file mode 100644 index 0000000000..e6d5914bd9 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -0,0 +1,456 @@ +/** + * ACL failure-path tests with stub binding tables (the failure-paths.spec.ts + * pattern): every checked Win32 call in the lock, read-merge-write, and + * grant-skip sequence has a failing counterpart, and each failure closes the + * handles it created before throwing. The exact-ACE skip and the DACL-walk + * defenses are driven through crafted in-memory ACL/SID buffers. Pure + * stubs — no real Win32 calls, so these run on every platform; the + * real-FFI round-trip lives in acl.spec.ts (win32 only). + */ + +import { tmpdir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { grantWrite, revokeWrite, withPathLock } from '../src/acl.ts' +import { allocBytes, ptrAddress } from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { Win32Error } from '../src/errors.ts' +import * as abi from '../src/win32-abi.ts' + +const PVOID = koffi.pointer('void') + +/** The stub the grant/revoke happy path needs; every call succeeds until a field is overridden per test. */ +function aclApi(overrides: Partial<Win32Bindings> = {}): Win32Bindings { + return { + getTempPathW: vi.fn((_length: number, buffer: Buffer) => { + const temp = tmpdir().replace(/[\\/]$/u, '') + buffer.write(temp, 'utf16le') + return temp.length + }), + createFileW: vi.fn(() => 7n), + lockFileEx: vi.fn(() => 1), + unlockFileEx: vi.fn(() => 1), + closeHandle: vi.fn(() => 1), + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one + koffi.encode(descriptor, PVOID, 0n) + return 0 + }), + setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => { + koffi.encode(newAcl, PVOID, 9n) + return 0 + }), + setNamedSecurityInfoW: vi.fn(() => 0), + localFree: vi.fn(() => 0n as NativePtr), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + ...overrides, + } as unknown as Win32Bindings +} + +/** One SID allocation: revision@0, subAuthorityCount@1, identifierAuthority@2 (6 bytes), subauthorities@8. */ +function craftSid(revision: number, count: number, authority: number[] = [0, 0, 0, 0, 0, 5]): NativePtr { + const sid = allocBytes(8) + koffi.encode(sid, 'uint8', revision) + koffi.encode(sid, 1, 'uint8', count) + authority.forEach((byte, index) => { + koffi.encode(sid, 2 + index, 'uint8', byte) + }) + return sid +} + +/** + * One in-memory ACL carrying the exact grant ACE the skip checks for: + * header (AclRevision@0, AclSize@2, AceCount@4) then one ACCESS_ALLOWED_ACE + * (AceType@0, AceFlags@1, AceSize@2, Mask@4, inline SID@8). `match` selects + * whether the inline SID bytes equal `sid`. + */ +function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr { + const acl = allocBytes(32) + koffi.encode(acl, 'uint8', 2) // AclRevision + koffi.encode(acl, 2, 'uint16', 16) // AclSize: header + one 8-byte-SID ACE + koffi.encode(acl, 4, 'uint16', 1) // AceCount + const ace = 8 + koffi.encode(acl, ace + 0, 'uint8', abi.ACCESS_ALLOWED_ACE_TYPE) + koffi.encode(acl, ace + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT) + koffi.encode(acl, ace + 2, 'uint16', 8) + koffi.encode(acl, ace + 4, 'uint32', abi.GRANT_MASK) + const inlineSid = ace + 8 + for (let offset = 0; offset < 8; offset++) { + koffi.encode(acl, inlineSid + offset, 'uint8', match + ? koffi.decode(sid, offset, 'uint8') as number + : offset === 0 ? 9 : 0) + } + return acl +} + +describe('withPathLock failure paths', () => { + it('fails closed when CreateFileW returns an invalid handle', () => { + const api = aclApi({ createFileW: vi.fn(() => 0n as NativePtr) }) + let caught: unknown + try { + withPathLock(api, 'C:\\locked', () => {}) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateFileW') + }) + + it('closes the handle and reports when LockFileEx fails', () => { + const closeHandle = vi.fn(() => 1) + const api = aclApi({ lockFileEx: vi.fn(() => 0), closeHandle }) + let caught: unknown + try { + withPathLock(api, 'C:\\locked', () => {}) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LockFileEx') + expect(closeHandle).toHaveBeenCalledWith(7n) + }) + + it('closes the handle and reports when UnlockFileEx fails', () => { + const closeHandle = vi.fn(() => 1) + const api = aclApi({ unlockFileEx: vi.fn(() => 0), closeHandle }) + let caught: unknown + try { + withPathLock(api, 'C:\\locked', () => {}) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('UnlockFileEx') + expect(closeHandle).toHaveBeenCalledWith(7n) + }) + + it('reports a failed CloseHandle after a successful action', () => { + const api = aclApi({ closeHandle: vi.fn(() => 0) }) + let caught: unknown + try { + withPathLock(api, 'C:\\locked', () => {}) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CloseHandle') + }) +}) + +describe('mergeAndApply failure paths', () => { + it('reports a SetEntriesInAclW failure when the directory carries no descriptor to free', () => { + const api = aclApi({ setEntriesInAclW: vi.fn(() => 5) }) // default descriptor: none + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + }) + + it('reports a NULL merged ACL when there is no descriptor to free', () => { + const api = aclApi({ setEntriesInAclW: vi.fn(() => 0) }) // no out slot write, no descriptor + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + }) + + it('frees the descriptor and reports when SetEntriesInAclW fails', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 6n) // an existing explicit DACL + return 0 + }), + setEntriesInAclW: vi.fn(() => 5), + localFree, + }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('frees the descriptor and reports a NULL merged ACL', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + setEntriesInAclW: vi.fn(() => 0), // success without writing the out slot + localFree, + }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('frees the merged ACL and reports when SetNamedSecurityInfoW fails', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ setNamedSecurityInfoW: vi.fn(() => 5), localFree }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetNamedSecurityInfoW') + expect(localFree).toHaveBeenCalledWith(9n) + }) + + it('reports a failed descriptor LocalFree after a successful apply', () => { + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + localFree: vi.fn(() => 1n as NativePtr), // both frees "fail"; the first is checked + }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalFree') + }) + + it('reports a failed merged-ACL LocalFree after a successful apply', () => { + // No existing descriptor (the default stub): the merge's only LocalFree + // is the merged ACL's, which "fails" and is checked after the apply. + const api = aclApi({ localFree: vi.fn(() => 1n as NativePtr) }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalFree') + }) +}) + +describe('the exact-ACE skip and DACL-walk defenses', () => { + it('grantWrite skips the apply when the standing exact ACE matches (descriptor freed, nothing merged)', () => { + const sid = craftSid(1, 0) + const localFree = vi.fn(() => 0n as NativePtr) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + localFree, + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid) + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('grantWrite skips the apply without freeing when the exact ACE stands but no descriptor owns it', () => { + const sid = craftSid(1, 0) + const localFree = vi.fn(() => 0n as NativePtr) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) + koffi.encode(descriptor, PVOID, 0n) // the read "returned" a bare ACL with no descriptor + return 0 + }), + localFree, + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid) + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + expect(localFree).not.toHaveBeenCalled() + }) + + it('grantWrite reports a failed descriptor LocalFree on the exact-ACE skip path', () => { + const sid = craftSid(1, 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + localFree: vi.fn(() => 1n as NativePtr), + }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalFree') + }) + + it('falls back to the merge path when the standing ACE names a different SID', () => { + const sid = craftSid(1, 0) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, false))) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('treats an implausibly small ACL size as no exact grant', () => { + const sid = craftSid(1, 0) + const acl = allocBytes(32) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 4) // smaller than the 8-byte ACL header + koffi.encode(acl, 4, 'uint16', 1) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(acl)) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('treats an ACE that would overrun the ACL as no exact grant', () => { + const sid = craftSid(1, 0) + const acl = allocBytes(32) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 8) // header only: no room for any ACE + koffi.encode(acl, 4, 'uint16', 1) + koffi.encode(acl, 10, 'uint16', 100) // the walk reads a lying ACE size + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, ptrAddress(acl)) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) +}) + +describe('revokeWrite no-DACL path', () => { + it('reports nothing to revoke when the read yields neither DACL nor descriptor', () => { + // The default stub encodes a NULL DACL and a NULL descriptor. + const api = aclApi() + const sid = craftSid(1, 0) + expect(revokeWrite(api, 'C:\\granted', sid)).toBe(false) + }) + + it('frees a descriptor that carries no DACL and reports nothing to revoke', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 6n) // descriptor WITHOUT a DACL + return 0 + }), + localFree, + }) + const sid = craftSid(1, 0) + expect(revokeWrite(api, 'C:\\granted', sid)).toBe(false) + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('reports a failed descriptor LocalFree on the no-DACL path', () => { + const api = aclApi({ + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 6n) + return 0 + }), + localFree: vi.fn(() => 1n as NativePtr), + }) + const sid = craftSid(1, 0) + let caught: unknown + try { + revokeWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalFree') + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts index 0c1d7f42b3..a6bea87998 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts @@ -11,7 +11,8 @@ import koffi from 'koffi' import { PROCESS_INFORMATION, getTempPath } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import { Win32Error } from '../src/errors.ts' -import { spawnSandboxed, spawnSandboxedInherited } from '../src/spawn.ts' +import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from '../src/spawn.ts' +import * as abi from '../src/win32-abi.ts' const PVOID = koffi.pointer('void') @@ -136,3 +137,318 @@ describe('getTempPath buffer defense', () => { expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u) }) }) + +/** The stub the pipe-happy path needs: CreatePipe fills both out slots with fresh handles. */ +function pipeOkApi(overrides: Partial<Win32Bindings> = {}): { + api: Win32Bindings + closed: bigint[] + closeHandle: ReturnType<typeof vi.fn> +} { + const closed: bigint[] = [] + let next = 1n + const closeHandle = vi.fn((handle: NativePtr) => { + closed.push(handle) + return 1 + }) + const api = { + createPipe: vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => { + koffi.encode(readSlot, PVOID, next++) + koffi.encode(writeSlot, PVOID, next++) + return 1 + }), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }), + getLastError: vi.fn(() => 5), + closeHandle, + formatMessageW: vi.fn(() => 0), + ...overrides, + } as unknown as Win32Bindings + return { api, closed, closeHandle } +} + +describe('spawn pipe failures close their handles', () => { + const token = 1n as NativePtr + + it('spawnSandboxed reports a CreatePipe failure', () => { + const api = { createPipe: vi.fn(() => 0), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings + let caught: unknown + try { + spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreatePipe') + }) + + it('spawnSandboxed reports a NULL pipe handle after CreatePipe succeeds', () => { + const api = { createPipe: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings + let caught: unknown + try { + spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreatePipe') + }) + + it('spawnSandboxed reports a SetHandleInformation failure', () => { + const { api } = pipeOkApi({ setHandleInformation: vi.fn(() => 0) }) + let caught: unknown + try { + spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetHandleInformation') + }) + + it('spawnSandboxed rejects NULL process/thread handles after a successful spawn', () => { + const { api } = pipeOkApi({ + createProcessAsUserW: vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }), + }) + expect(() => spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })) + .toThrow(/null process\/thread handles/u) + }) +}) + +describe('spawnSandboxedInherited failure paths', () => { + const token = 1n as NativePtr + + /** The stub the inherited-happy path needs; overrides flip one call per test. */ + function inheritedApi(overrides: Partial<Win32Bindings> = {}): { + api: Win32Bindings + closed: bigint[] + closeHandle: ReturnType<typeof vi.fn> + } { + const closed: bigint[] = [] + let std = 50n + const closeHandle = vi.fn((handle: NativePtr) => { + closed.push(handle) + return 1 + }) + const api = { + createJobObjectW: vi.fn(() => 100n), + setInformationJobObject: vi.fn(() => 1), + getStdHandle: vi.fn(() => std++), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }), + assignProcessToJobObject: vi.fn(() => 1), + resumeThread: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + closeHandle, + formatMessageW: vi.fn(() => 0), + ...overrides, + } as unknown as Win32Bindings + return { api, closed, closeHandle } + } + + it('closes the job and reports when GetStdHandle yields a NULL handle', () => { + const { api, closeHandle } = inheritedApi({ getStdHandle: vi.fn(() => 0n as NativePtr) }) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetStdHandle') + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('reports a SetHandleInformation failure while enabling stdio inheritance', () => { + const { api } = inheritedApi({ setHandleInformation: vi.fn(() => 0) }) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetHandleInformation') + }) + + it('closes the job and reports when CreateProcessAsUserW fails', () => { + const { api, closeHandle } = inheritedApi({ createProcessAsUserW: vi.fn(() => 0) }) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateProcessAsUserW') + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('closes the job and rejects NULL process/thread handles after a successful spawn', () => { + const { api, closeHandle } = inheritedApi({ + createProcessAsUserW: vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }), + }) + expect(() => spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })) + .toThrow(/null process\/thread handles/u) + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('closes the job and reports when SetInformationJobObject fails', () => { + const { api, closeHandle } = inheritedApi({ setInformationJobObject: vi.fn(() => 0) }) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetInformationJobObject') + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('closes the job and reports a NULL job object', () => { + const { api } = inheritedApi({ createJobObjectW: vi.fn(() => 0n as NativePtr) }) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateJobObjectW') + }) + + it('returns the pid, process handle, and kill-on-close job when every call succeeds', () => { + const { api, closeHandle } = inheritedApi() + const spawned = spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + expect(spawned.pid).toBe(1234) + expect(spawned.process).toBe(200n) + expect(spawned.job).toBe(100n) + // thread handle closed by the spawn; process and job handles stay with the caller. + expect(closeHandle).toHaveBeenCalledWith(201n) + expect(closeHandle).not.toHaveBeenCalledWith(200n) + expect(closeHandle).not.toHaveBeenCalledWith(100n) + }) +}) + +describe('drainPipe', () => { + it('stops at ERROR_NO_DATA and closes the read end', () => { + const closeHandle = vi.fn(() => 1) + const api = { + peekNamedPipe: vi.fn(() => 0), + getLastError: vi.fn(() => abi.ERROR_NO_DATA), + closeHandle, + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return drainPipe(api, 30n as NativePtr).then((buffer) => { + expect(buffer.length).toBe(0) + expect(closeHandle).toHaveBeenCalledWith(30n) + }) + }) + + it('reports a PeekNamedPipe failure that is not a clean EOF', () => { + const api = { + peekNamedPipe: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + closeHandle: vi.fn(() => 1), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'PeekNamedPipe' }) + }) + + it('reports a ReadFile failure after data was reported available', () => { + const api = { + peekNamedPipe: vi.fn((_pipe: unknown, _buffer: unknown, _size: unknown, _read: unknown, totalAvail: NativePtr) => { + koffi.encode(totalAvail, 'uint32', 4) + return 1 + }), + readFile: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + closeHandle: vi.fn(() => 1), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'ReadFile' }) + }) + + it('drains one chunk and stops at ERROR_BROKEN_PIPE', () => { + let peeks = 0 + const api = { + peekNamedPipe: vi.fn((_pipe: unknown, _buffer: unknown, _size: unknown, _read: unknown, totalAvail: NativePtr) => { + peeks++ + if (peeks > 1) return 0 + koffi.encode(totalAvail, 'uint32', 4) + return 1 + }), + readFile: vi.fn((_file: unknown, chunk: Buffer, _count: unknown, read: NativePtr) => { + chunk.write('ab', 0, 'utf8') + koffi.encode(read, 'uint32', 2) + return 1 + }), + getLastError: vi.fn(() => abi.ERROR_BROKEN_PIPE), + closeHandle: vi.fn(() => 1), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return drainPipe(api, 30n as NativePtr).then((buffer) => { + expect(buffer.toString('utf8')).toBe('ab') + }) + }) +}) + +describe('waitForExit', () => { + it('reports a WaitForSingleObject failure', () => { + const api = { + waitForSingleObject: vi.fn(() => 0xFFFFFFFF), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error) + }) + + it('reports a GetExitCodeProcess failure', () => { + const api = { + waitForSingleObject: vi.fn(() => 0), + getExitCodeProcess: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => waitForExit(api, 200n as NativePtr)).toThrow(Win32Error) + }) + + it('returns the exit code and closes the process handle', () => { + const closeHandle = vi.fn(() => 1) + const api = { + waitForSingleObject: vi.fn(() => 0), + getExitCodeProcess: vi.fn((_process: unknown, slot: NativePtr) => { + koffi.encode(slot, 'uint32', 42) + return 1 + }), + closeHandle, + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(waitForExit(api, 200n as NativePtr)).toBe(42) + expect(closeHandle).toHaveBeenCalledWith(200n) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts new file mode 100644 index 0000000000..8388911598 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -0,0 +1,190 @@ +/** + * FFI helper tests with stub binding tables (the failure-paths.spec.ts + * pattern): error formatting and temp-path decoding defenses, the + * last-error throwers' detail fallback, pointer decode NULL handling, and + * the bounded SID comparison's early exits. Pure stubs — no real Win32 + * calls, so these run on every platform; the real-FFI round-trip lives in + * acl.spec.ts and probe.spec.ts (win32 only). + */ + +import { describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { Win32Error } from '../src/errors.ts' +import { + allocBytes, decodePtr, decodePtrAt, errorText, getTempPath, + isInvalidHandle, isNullPtr, sameSidAt, throwLastError, throwWin32, +} from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import * as abi from '../src/win32-abi.ts' + +const PVOID = koffi.pointer('void') + +/** A stub whose formatMessageW writes real UTF-16 text (the errorText round-trip). */ +function formatApi(): { api: Win32Bindings; formatMessageW: ReturnType<typeof vi.fn> } { + const formatMessageW = vi.fn((_flags: number, _source: null, _id: number, _lang: number, buffer: Buffer, _size: number, _args: null) => { + const text = 'access denied' + buffer.write(text, 'utf16le') + return text.length + }) + const api = { + formatMessageW, + getLastError: vi.fn(() => 5), + } as unknown as Win32Bindings + return { api, formatMessageW } +} + +/** A minimal SID allocation: revision@0, subAuthorityCount@1, identifierAuthority@2, subauthorities@8. */ +function craftSid(revision: number, count: number, authority: number[] = [0, 0, 0, 0, 0, 0], subs: number[] = []): NativePtr { + const sid = allocBytes(8 + subs.length * 4) + koffi.encode(sid, 'uint8', revision) + koffi.encode(sid, 1, 'uint8', count) + authority.forEach((byte, index) => { + koffi.encode(sid, 2 + index, 'uint8', byte) + }) + subs.forEach((sub, index) => { + koffi.encode(sid, 8 + index * 4, 'uint32', sub) + }) + return sid +} + +describe('errorText', () => { + it('decodes the formatted UTF-16 message and trims it', () => { + const { api } = formatApi() + expect(errorText(api, 5)).toBe('access denied') + }) + + it('returns an empty string when FormatMessageW formats nothing', () => { + const api = { formatMessageW: vi.fn(() => 0) } as unknown as Win32Bindings + expect(errorText(api, 5)).toBe('') + }) +}) + +describe('getTempPath', () => { + it('decodes the NUL-terminated temp path GetTempPathW wrote', () => { + const api = { + getTempPathW: vi.fn((_length: number, buffer: Buffer) => { + buffer.write('C:\\TEMP', 'utf16le') + return 7 + }), + } as unknown as Win32Bindings + expect(getTempPath(api)).toBe('C:\\TEMP') + }) + + it('reports the Win32 failure when GetTempPathW writes nothing', () => { + const { api } = formatApi() + const failing = { ...api, getTempPathW: vi.fn(() => 0) } as Win32Bindings + let caught: unknown + try { + getTempPath(failing) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTempPathW') + }) +}) + +describe('throwLastError and throwWin32', () => { + it('throwLastError formats the system message when no detail is given', () => { + const { api } = formatApi() + let caught: unknown + try { + throwLastError(api, 'Probe') + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).message).toContain('Probe failed (Win32 5): access denied') + }) + + it('throwWin32 formats the system message when no detail is given', () => { + const { api } = formatApi() + let caught: unknown + try { + throwWin32(api, 'Probe', 5) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).message).toContain('Probe failed (Win32 5): access denied') + }) + + it('Win32Error appends the detail when one is given', () => { + const error = new Win32Error('Probe', 5, 'the lock file path') + expect(error.name).toBe('Win32Error') + expect(error.api).toBe('Probe') + expect(error.win32Code).toBe(5) + expect(error.message).toBe('Probe failed (Win32 5): the lock file path') + }) + + it('Win32Error omits the detail suffix when none is given', () => { + const error = new Win32Error('Probe', 5) + expect(error.message).toBe('Probe failed (Win32 5)') + }) +}) + +describe('pointer NULL handling', () => { + it('isNullPtr accepts null, undefined, and the zero pointer', () => { + expect(isNullPtr(null)).toBe(true) + expect(isNullPtr(undefined)).toBe(true) + expect(isNullPtr(0n as NativePtr)).toBe(true) + expect(isNullPtr(42n as NativePtr)).toBe(false) + }) + + it('isInvalidHandle treats NULL as failure', () => { + expect(isInvalidHandle(null)).toBe(true) + expect(isInvalidHandle(undefined)).toBe(true) + expect(isInvalidHandle(0n as NativePtr)).toBe(true) + expect(isInvalidHandle(42n as NativePtr)).toBe(false) + }) + + it('decodePtrAt returns null for a NULL pointer stored in a buffer', () => { + const buffer = Buffer.alloc(8) + buffer.writeBigUInt64LE(0n, 0) + expect(decodePtrAt(buffer, 0)).toBeNull() + }) + + it('decodePtrAt returns the stored pointer value', () => { + const buffer = Buffer.alloc(8) + buffer.writeBigUInt64LE(42n, 0) + expect(decodePtrAt(buffer, 0)).toBe(42n) + }) + + it('decodePtr returns null for an unset out-parameter slot', () => { + const slot = koffi.alloc(PVOID, 1) as unknown as NativePtr + expect(decodePtr(slot)).toBeNull() + }) +}) + +describe('sameSidAt bounded comparison', () => { + it('rejects a revision mismatch before comparing anything else', () => { + const left = craftSid(1, 0) + const right = craftSid(2, 0) + expect(sameSidAt(left, 0, right, 0)).toBe(false) + }) + + it('rejects a subauthority-count mismatch', () => { + const left = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) + const right = craftSid(1, 2, [0, 0, 0, 0, 0, 5], [42, 43]) + expect(sameSidAt(left, 0, right, 0)).toBe(false) + }) + + it('rejects an implausible subauthority count', () => { + const left = craftSid(1, abi.SID_MAX_SUB_AUTHORITIES + 1) + const right = craftSid(1, abi.SID_MAX_SUB_AUTHORITIES + 1) + expect(sameSidAt(left, 0, right, 0)).toBe(false) + }) + + it('rejects a differing identifier authority byte', () => { + const left = craftSid(1, 0, [0, 0, 0, 0, 0, 5]) + const right = craftSid(1, 0, [0, 0, 0, 0, 0, 6]) + expect(sameSidAt(left, 0, right, 0)).toBe(false) + }) + + it('accepts identical SIDs at nonzero offsets', () => { + const left = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) + const right = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) + expect(sameSidAt(left, 4, right, 4)).toBe(true) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts new file mode 100644 index 0000000000..87fc23ea9f --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -0,0 +1,388 @@ +/** + * AclSandbox orchestration failure-path tests: the win32 resolver is mocked + * to hand each test a stub binding table, so every checked Win32 call in + * init/spawn/dispose has a failing counterpart without opening real token or + * ACL handles. Constructor validation, the fail-closed init cleanup, and the + * dispose aggregation use the same stubs. Pure stubs — no real Win32 calls, + * so these run on every platform; the real-FFI round-trip lives in + * acl.spec.ts and runner.spec.ts (win32 only). + */ + +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { PROCESS_INFORMATION } from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { Win32Error } from '../src/errors.ts' +import { AclSandbox } from '../src/index.ts' +import * as abi from '../src/win32-abi.ts' + +const PVOID = koffi.pointer('void') + +type MockFn = ReturnType<typeof vi.fn> + +/** The stub binding table plus the mocks the assertions inspect directly. */ +interface HappyStubs { + api: Win32Bindings + setNamedSecurityInfoW: MockFn + convertStringSidToSidW: MockFn + closeHandle: MockFn + localFree: MockFn + createRestrictedToken: MockFn + createJobObjectW: MockFn + getNamedSecurityInfoW: MockFn +} + +const state = vi.hoisted(() => ({ stubs: undefined as HappyStubs | undefined })) + +vi.mock('../src/ffi.ts', async (importOriginal) => { + const actual = await importOriginal<typeof import('../src/ffi.ts')>() + return { + ...actual, + win32: () => Promise.resolve(state.stubs?.api as Win32Bindings), + win32Sync: () => state.stubs?.api as Win32Bindings, + } +}) + +const scratchDirs: string[] = [] +afterAll(() => { + for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-index-')) + scratchDirs.push(dir) + return dir +} + +/** + * The stub the whole happy pipeline needs: token opening, write-SID parse, + * workspace+temp grants, logon-SID scan, well-known SID, restricted token, + * default-DACL merge, piped/inherited spawns, drains, and exit waits all + * succeed. Every test flips one call per branch. + */ +function happyStubs(): HappyStubs { + let next = 0n + const fresh = () => ++next + + const openProcess = vi.fn(() => fresh()) + const openProcessToken = vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => { + koffi.encode(slot, PVOID, fresh()) + return 1 + }) + const convertStringSidToSidW = vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, fresh()) + return 1 + }) + const getTempPathW = vi.fn((_length: number, buffer: Buffer) => { + const temp = tmpdir().replace(/[\\/]$/u, '') + buffer.write(temp, 'utf16le') + return temp.length + }) + const createFileW = vi.fn(() => fresh()) + const getNamedSecurityInfoW = vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 0n) + return 0 + }) + const setEntriesInAclW = vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => { + koffi.encode(newAcl, PVOID, fresh()) + return 0 + }) + const setNamedSecurityInfoW = vi.fn(() => 0) + const getTokenInformation = vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => { + if (info === null) { + koffi.encode(needed, 'uint32', cls === abi.TokenGroups ? 24 : 8) + return 0 // the size probe is expected to "fail" + } + if (cls === abi.TokenGroups) { + info.writeUInt32LE(1, 0) + info.writeBigUInt64LE(77n, abi.TOKEN_GROUPS_OFFSET) + info.writeUInt32LE(abi.SE_GROUP_LOGON_ID, abi.TOKEN_GROUPS_OFFSET + 8) + } else { + info.writeBigUInt64LE(88n, 0) // the token's current default DACL + } + return 1 + }) + const getLengthSid = vi.fn(() => 12) + const copySid = vi.fn(() => 1) + const createWellKnownSid = vi.fn(() => 1) + const isValidSid = vi.fn(() => 1) + const createRestrictedToken = vi.fn(( + _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, + _rc: unknown, _rs: unknown, slot: NativePtr, + ) => { + koffi.encode(slot, PVOID, fresh()) + return 1 + }) + const setTokenInformation = vi.fn(() => 1) + const createPipe = vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => { + koffi.encode(readSlot, PVOID, fresh()) + koffi.encode(writeSlot, PVOID, fresh()) + return 1 + }) + const setHandleInformation = vi.fn(() => 1) + const createProcessAsUserW = vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: fresh(), hThread: fresh(), dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }) + const peekNamedPipe = vi.fn(() => 0) + const readFile = vi.fn(() => 1) + const waitForSingleObject = vi.fn(() => 0) + const getExitCodeProcess = vi.fn((_process: unknown, slot: NativePtr) => { + koffi.encode(slot, 'uint32', 42) + return 1 + }) + const createJobObjectW = vi.fn(() => fresh()) + const setInformationJobObject = vi.fn(() => 1) + const assignProcessToJobObject = vi.fn(() => 1) + const resumeThread = vi.fn(() => 0) + const getStdHandle = vi.fn(() => fresh()) + const localFree = vi.fn(() => 0n) + const closeHandle = vi.fn(() => 1) + const getLastError = vi.fn(() => abi.ERROR_BROKEN_PIPE) // the drains' clean EOF + const formatMessageW = vi.fn(() => 0) + + const api = { + openProcess, openProcessToken, convertStringSidToSidW, getTempPathW, createFileW, + lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1), + getNamedSecurityInfoW, setEntriesInAclW, setNamedSecurityInfoW, getTokenInformation, + getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken, + setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW, + peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW, + setInformationJobObject, assignProcessToJobObject, resumeThread, getStdHandle, + localFree, closeHandle, getLastError, formatMessageW, + } as unknown as Win32Bindings + return { + api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree, + createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, + } +} + +beforeEach(() => { + state.stubs = happyStubs() +}) + +describe('AclSandbox constructor validation', () => { + it('rejects a writable directory that does not exist', () => { + const missing = join(scratch(), 'missing') + expect(() => new AclSandbox({ writableDirs: [missing], tempDir: null, mode: 'read-only' })) + .toThrow(/writable dir does not exist/u) + }) + + it('resolves relative writable directories to absolute paths', () => { + const dir = scratch() + const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'read-only' }) + expect(sandbox.writableDirs).toEqual([resolve(dir)]) + expect(sandbox.mode).toBe('read-only') + expect(sandbox.tempDir).toBeUndefined() + }) +}) + +describe('AclSandbox init', () => { + it('completes the happy workspace-write pipeline: workspace and temp grants, restricted token, resolved temp dir', async () => { + const { setNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const temp = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' }) + await sandbox.init() + expect(sandbox.tempDir).toBe(resolve(temp)) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(2) + }) + + it('defaults the temp dir to GetTempPathW when no tempDir option is given', async () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }) + await sandbox.init() + expect(sandbox.tempDir).toBe(tmpdir().replace(/[\\/]$/u, '')) + }) + + it('applies no grants when the temp dir option is null', async () => { + const { setNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' }) + await sandbox.init() + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) // workspace only + }) + + it('rejects a temp dir that does not exist', async () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: join(scratch(), 'missing'), writeSid: 'S-1-4-9000-4', mode: 'workspace-write' }) + await expect(sandbox.init()).rejects.toThrow(/temp dir does not exist/u) + }) + + it('builds a read-only token without parsing a write SID or applying grants', async () => { + const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'read-only' }) + await sandbox.init() + expect(convertStringSidToSidW).not.toHaveBeenCalled() + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + expect(() => { sandbox.dispose() }).not.toThrow() // no write SID: nothing to revoke or free + }) + + it('applies no grants when the caller owns the DACLs (manageDacls: false)', async () => { + const { setNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-5', mode: 'workspace-write', manageDacls: false }) + await sandbox.init() + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + expect(() => { sandbox.dispose() }).not.toThrow() // caller-owned DACLs: nothing to revoke + }) + + it('refuses a second init on the same instance', async () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-6', mode: 'workspace-write' }) + await sandbox.init() + await expect(sandbox.init()).rejects.toThrow(/already initialized/u) + }) + + it('reports a ConvertStringSidToSidW failure before granting anything', async () => { + const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs + convertStringSidToSidW.mockReturnValue(0) + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-7', mode: 'workspace-write' }) + await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' }) + expect(setNamedSecurityInfoW).not.toHaveBeenCalled() + }) + + it('rejects a NULL write SID after ConvertStringSidToSidW succeeds', async () => { + const { convertStringSidToSidW } = state.stubs as HappyStubs + convertStringSidToSidW.mockImplementation(() => 1) // no out slot write + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-8', mode: 'workspace-write' }) + await expect(sandbox.init()).rejects.toBeInstanceOf(Win32Error) + }) + + it('reports a failed close of the current process token', async () => { + const { closeHandle } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-9', mode: 'workspace-write' }) + // fresh() hands out 1n to OpenProcess and 2n to OpenProcessToken; the + // token-layer close of 1n succeeds and init's close of 2n fails. + closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n ? 0 : 1)) + await expect(sandbox.init()).rejects.toMatchObject({ api: 'CloseHandle' }) + // The failed init never stored a restricted token: dispose skips the + // token close and the already-drained allocations. + expect(() => { sandbox.dispose() }).not.toThrow() + }) + + it('revokes the revocable grants and aggregates cleanup failures when the token pipeline fails', async () => { + const { createRestrictedToken, localFree, getNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const temp = scratch() + let inCleanup = false + createRestrictedToken.mockImplementation(() => { + inCleanup = true // the grants already landed: every later call is the cleanup's + return 0 + }) + localFree.mockImplementation(() => (inCleanup ? 1n : 0n)) + getNamedSecurityInfoW.mockImplementation(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + if (inCleanup) return 2 // the cleanup's revocation read fails too + koffi.encode(dacl, PVOID, 0n) + koffi.encode(descriptor, PVOID, 0n) + return 0 + }) + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-10', mode: 'workspace-write' }) + await expect(sandbox.init()).rejects.toThrow(/3 grant revocation\(s\) also failed/u) + }) +}) + +describe('AclSandbox spawn', () => { + it('refuses to spawn before init', () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-11', mode: 'workspace-write' }) + expect(() => sandbox.spawn({ command: 'probe.exe' })).toThrow(/not initialized/u) + }) + + it('pipe spawn drains empty pipes and settles with the child exit code', async () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-12', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe', args: ['--flag'], cwd: workspace }) + expect(child.pid).toBe(1234) + const expected = { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 } + await expect(child.wait()).resolves.toEqual(expected) + // The second wait reuses the settled exit-code promise instead of re-waiting. + await expect(child.wait()).resolves.toEqual(expected) + }) + + it('inherit spawn settles with empty stdio and closes the kill-on-close job', async () => { + const { closeHandle } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-13', mode: 'workspace-write' }) + await sandbox.init() + const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) + await expect(child.wait()).resolves.toEqual({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 }) + expect(closeHandle).toHaveBeenCalled() + }) + + it('inherit spawn reports a failed close of the kill-on-close job', async () => { + const { closeHandle, createJobObjectW } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14', mode: 'workspace-write' }) + await sandbox.init() + let jobHandle = 0n + closeHandle.mockImplementation((handle: NativePtr) => (handle === jobHandle ? 0 : 1)) + const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) + jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr + await expect(child.wait()).rejects.toMatchObject({ api: 'CloseHandle' }) + }) +}) + +describe('AclSandbox dispose', () => { + it('is a no-op before init', () => { + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-15', mode: 'workspace-write' }) + expect(() => { sandbox.dispose() }).not.toThrow() + }) + + it('aggregates a failing temp revocation into an AggregateError', async () => { + const { getNamedSecurityInfoW } = state.stubs as HappyStubs + const workspace = scratch() + const temp = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-16', mode: 'workspace-write' }) + await sandbox.init() + getNamedSecurityInfoW.mockReturnValue(2) + expect(() => { sandbox.dispose() }).toThrow(/1 cleanup failure/u) + }) + + it('aggregates SID and token cleanup failures into an AggregateError', async () => { + const { localFree } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-17', mode: 'workspace-write' }) + await sandbox.init() + localFree.mockReturnValue(1n) + expect(() => { sandbox.dispose() }).toThrow(AggregateError) + }) + + it('reports a failed close of the restricted token', async () => { + const { createRestrictedToken, closeHandle } = state.stubs as HappyStubs + const workspace = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-18', mode: 'workspace-write' }) + let restrictedToken = 0n + createRestrictedToken.mockImplementation(( + _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, + _rc: unknown, _rs: unknown, slot: NativePtr, + ) => { + restrictedToken = 99n + koffi.encode(slot, PVOID, restrictedToken) + return 1 + }) + closeHandle.mockImplementation((handle: NativePtr) => (handle === restrictedToken ? 0 : 1)) + await sandbox.init() + expect(() => { sandbox.dispose() }).toThrow(AggregateError) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts new file mode 100644 index 0000000000..046a87664a --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts @@ -0,0 +1,436 @@ +/** + * Restricted-token failure-path tests with stub binding tables (the + * failure-paths.spec.ts pattern): every checked Win32 call in the token + * pipeline — open, logon-SID scan, well-known SID creation, default-DACL + * merge, restricted-token creation — has a failing counterpart, and each + * failure closes or frees what it created before throwing. Pure stubs — no + * real Win32 calls, so these run on every platform; the real-FFI round-trip + * lives in acl.spec.ts (win32 only). + */ + +import { describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { allocBytes, isNullPtr } from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { Win32Error } from '../src/errors.ts' +import { + createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant, +} from '../src/token.ts' +import * as abi from '../src/win32-abi.ts' + +const PVOID = koffi.pointer('void') + +describe('openCurrentProcessToken failure paths', () => { + it('reports when OpenProcess yields no handle', () => { + const api = { + openProcess: vi.fn(() => 0n), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + openCurrentProcessToken(api) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('OpenProcess') + }) + + it('closes the process handle and reports when OpenProcessToken fails', () => { + const closeHandle = vi.fn(() => 1) + const api = { + openProcess: vi.fn(() => 7n), + openProcessToken: vi.fn(() => 0), + closeHandle, + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + openCurrentProcessToken(api) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('OpenProcessToken') + expect(closeHandle).toHaveBeenCalledWith(7n) + }) + + it('reports a failed CloseHandle of the process handle', () => { + const api = { + openProcess: vi.fn(() => 7n), + openProcessToken: vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => { + koffi.encode(slot, PVOID, 9n) + return 1 + }), + closeHandle: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + openCurrentProcessToken(api) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CloseHandle') + }) + + it('rejects a NULL token handle after a successful OpenProcessToken', () => { + const api = { + openProcess: vi.fn(() => 7n), + openProcessToken: vi.fn(() => 1), // succeeds without writing the out slot + closeHandle: vi.fn(() => 1), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + openCurrentProcessToken(api) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('OpenProcessToken') + }) +}) + +/** + * The stub the logon-SID scan needs: the size probe writes `needed`, the + * second call fills a TOKEN_GROUPS buffer (GroupCount@0, SID pointer@8, + * attributes@16) with the state's one group. The CopySid mock comes back + * beside the table for the one test that asserts on its arguments. + */ +function logonApi(state: { + needed: number + groupCount: number + sidPtr: bigint + logon: boolean + secondOk?: boolean + sidLength?: number + copyOk?: boolean +}): { api: Win32Bindings; copySid: ReturnType<typeof vi.fn> } { + const copySid = vi.fn(() => (state.copyOk === false ? 0 : 1)) + const api = { + getTokenInformation: vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => { + if (cls !== abi.TokenGroups) throw new Error(`unexpected token information class ${cls}`) + if (info === null) { + koffi.encode(needed, 'uint32', state.needed) + return 0 // the size probe is expected to "fail" + } + if (state.secondOk === false) return 0 + info.writeUInt32LE(state.groupCount, 0) + if (state.groupCount > 0) { + info.writeBigUInt64LE(state.sidPtr, abi.TOKEN_GROUPS_OFFSET) + info.writeUInt32LE(state.logon ? abi.SE_GROUP_LOGON_ID : 0, abi.TOKEN_GROUPS_OFFSET + 8) + } + return 1 + }), + getLengthSid: vi.fn(() => state.sidLength ?? 12), + copySid, + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return { api, copySid } +} + +describe('findLogonSid failure paths', () => { + const token = 9n as NativePtr + + it('reports a size probe that wrote nothing', () => { + const { api } = logonApi({ needed: 0, groupCount: 0, sidPtr: 0n, logon: false }) + let caught: unknown + try { + findLogonSid(api, token) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTokenInformation') + }) + + it('rejects an implausibly small TokenGroups size', () => { + const { api } = logonApi({ needed: 4, groupCount: 0, sidPtr: 0n, logon: false }) + let caught: unknown + try { + findLogonSid(api, token) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTokenInformation') + }) + + it('reports a failed TokenGroups read', () => { + const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, secondOk: false }) + let caught: unknown + try { + findLogonSid(api, token) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTokenInformation') + }) + + it('skips a NULL group SID pointer and throws when no logon SID remains', () => { + const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 0n, logon: true }) + expect(() => findLogonSid(api, token)).toThrow(/no logon SID found/u) + }) + + it('skips a non-logon group and throws when no logon SID remains', () => { + const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: false }) + expect(() => findLogonSid(api, token)).toThrow(/no logon SID found/u) + }) + + it('reports a zero logon-SID length', () => { + const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, sidLength: 0 }) + let caught: unknown + try { + findLogonSid(api, token) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetLengthSid') + }) + + it('reports a failed CopySid of the logon SID', () => { + const { api } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true, copyOk: false }) + let caught: unknown + try { + findLogonSid(api, token) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CopySid') + }) + + it('copies the logon SID and returns the new allocation', () => { + const { api, copySid } = logonApi({ needed: 24, groupCount: 1, sidPtr: 77n, logon: true }) + const copy = findLogonSid(api, token) + expect(isNullPtr(copy)).toBe(false) + expect(copySid).toHaveBeenCalledWith(12, copy, 77n) + }) +}) + +describe('makeWellKnownSid failure paths', () => { + it('reports when CreateWellKnownSid fails', () => { + const api = { + createWellKnownSid: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + makeWellKnownSid(api, abi.WinWorldSid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateWellKnownSid') + }) + + it('reports when the created well-known SID is invalid', () => { + const api = { + createWellKnownSid: vi.fn(() => 1), + isValidSid: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + makeWellKnownSid(api, abi.WinWorldSid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('IsValidSid') + }) +}) + +/** + * The stub the default-DACL merge needs: the size probe writes `needed`, the + * second call fills the DACL pointer slot, and the merge/apply calls follow + * the state's results. + */ +function daclApi(state: { + needed: number + currentDacl: bigint + secondOk?: boolean + mergeResult?: number + newDacl: bigint + setTokenInfo?: number +}): Win32Bindings { + const api = { + getTokenInformation: vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => { + if (cls !== abi.TokenDefaultDacl) throw new Error(`unexpected token information class ${cls}`) + if (info === null) { + koffi.encode(needed, 'uint32', state.needed) + return 0 // the size probe is expected to "fail" + } + if (state.secondOk === false) return 0 + info.writeBigUInt64LE(state.currentDacl, 0) + return 1 + }), + setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => { + if (state.mergeResult !== undefined && state.mergeResult !== 0) return state.mergeResult + koffi.encode(newAcl, PVOID, state.newDacl) + return 0 + }), + setTokenInformation: vi.fn(() => state.setTokenInfo ?? 1), + localFree: vi.fn(() => 0n), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return api +} + +describe('setTokenDefaultDaclGrant failure paths', () => { + const token = 9n as NativePtr + const sid = 77n as NativePtr + + it('reports a size probe that wrote nothing', () => { + const api = daclApi({ needed: 0, currentDacl: 0n, newDacl: 0n }) + let caught: unknown + try { + setTokenDefaultDaclGrant(api, token, sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTokenInformation') + }) + + it('reports a failed default-DACL read', () => { + const api = daclApi({ needed: 8, currentDacl: 88n, secondOk: false, newDacl: 0n }) + let caught: unknown + try { + setTokenDefaultDaclGrant(api, token, sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetTokenInformation') + }) + + it('rejects a token that carries no default DACL', () => { + const api = daclApi({ needed: 8, currentDacl: 0n, newDacl: 0n }) + expect(() => { setTokenDefaultDaclGrant(api, token, sid) }).toThrow(/no default DACL/u) + }) + + it('reports a failed SetEntriesInAclW merge', () => { + const api = daclApi({ needed: 8, currentDacl: 88n, mergeResult: 5, newDacl: 0n }) + let caught: unknown + try { + setTokenDefaultDaclGrant(api, token, sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + }) + + it('rejects a NULL merged default DACL', () => { + const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 0n }) + let caught: unknown + try { + setTokenDefaultDaclGrant(api, token, sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + }) + + it('frees the merged DACL and reports when SetTokenInformation fails', () => { + const localFree = vi.fn(() => 0n) + const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 99n, setTokenInfo: 0 }) + ;(api.localFree as unknown as ReturnType<typeof vi.fn>).mockImplementation(localFree) + let caught: unknown + try { + setTokenDefaultDaclGrant(api, token, sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetTokenInformation') + expect(localFree).toHaveBeenCalledWith(99n) + }) + + it('frees the merged DACL after a successful apply', () => { + const localFree = vi.fn(() => 0n) + const api = daclApi({ needed: 8, currentDacl: 88n, newDacl: 99n }) + ;(api.localFree as unknown as ReturnType<typeof vi.fn>).mockImplementation(localFree) + setTokenDefaultDaclGrant(api, token, sid) + expect(localFree).toHaveBeenCalledWith(99n) + }) +}) + +describe('createRestrictedToken failure paths', () => { + it('builds the read-only restricting list without a write SID', () => { + const create = vi.fn(( + _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, + count: number, _sids: unknown, slot: NativePtr, + ) => { + koffi.encode(slot, PVOID, 9n) + expect(count).toBe(2) + return 1 + }) + const api = { createRestrictedToken: create } as unknown as Win32Bindings + const logon = allocBytes(12) + expect(createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only')).toBe(9n) + }) + + it('builds the workspace-write restricting list with the write SID', () => { + const create = vi.fn(( + _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, + count: number, _sids: unknown, slot: NativePtr, + ) => { + koffi.encode(slot, PVOID, 9n) + expect(count).toBe(3) + return 1 + }) + const api = { createRestrictedToken: create } as unknown as Win32Bindings + const logon = allocBytes(12) + expect(createRestrictedToken(api, 1n as NativePtr, logon, 3n as NativePtr, { world: 2n as NativePtr }, 'workspace-write')).toBe(9n) + }) + + it('reports when CreateRestrictedToken fails', () => { + const api = { + createRestrictedToken: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + const logon = allocBytes(12) + let caught: unknown + try { + createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only') + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateRestrictedToken') + }) + + it('rejects a NULL token handle after a successful CreateRestrictedToken', () => { + const api = { + createRestrictedToken: vi.fn(() => 1), // succeeds without writing the out slot + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + const logon = allocBytes(12) + let caught: unknown + try { + createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only') + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateRestrictedToken') + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 68bfafec8d..246f0e9a4d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -47,6 +47,15 @@ const windowsOnlyCoverageExclusions = process.platform !== 'win32' ] : [] +// The confinement runner entry executes exclusively as a spawned child +// process (the sandbox seam's argv-prefix wrapper): its module-level main() +// would run the confinement in-process if imported, and vitest's v8 coverage +// never measures child processes. Its behavior is pinned end-to-end by +// tests/runner.spec.ts, which spawns the real entry through tsx. +const windowsRunnerCoverageExclusions = process.platform === 'win32' + ? ['packages/sandbox/sandbox-windows-acl/src/runner.ts'] + : [] + // pwsh-local's run/start/lifecycle suites self-skip without a real pwsh // (executor.spec.ts hasPwsh), leaving this file // far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts @@ -229,6 +238,7 @@ export default defineConfig({ 'packages/session/session-projection/src/index.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), ...windowsOnlyCoverageExclusions, + ...windowsRunnerCoverageExclusions, ...pwshCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). From 501c3a8ab68f44628551eeac16332a53e41c94a7 Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Mon, 10 Aug 2026 19:17:37 +0800 Subject: [PATCH 104/229] fix(subagent): pin delegated child approvals to 'never' within the inherited sandbox scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A delegated in-process child now acts only within the sandbox scope fixed at delegation: captureDelegatedPolicyOverrides still snapshots the parent's explicit sandbox override but pins the child approval policy to 'never' (instead of inheriting the parent's), so every child ask — sandbox_permissions escalations included — is rejected deterministically by ApprovalService before any answerer, with the audit pair still logged. Every in-process child additionally receives the scoped subagent:delegation runtime-context statement telling it to report a scope limitation instead of retrying. Supersedes the approval half of the policy-inheritance decision (new Agent Note cross-linked from both prior notes and the approval-seam Q&A); refreshed child snapshot fixtures carry the pinned event, and subagent-published-run-failure now persists a one-event child log. --- .../2026-07-06-approval-seam.i18n.yaml | 4 +- .../feature/2026-07-06-approval-seam.md | 2 +- .../feature/2026-07-06-approval-seam.zh.md | 2 +- ...7-25-subagent-policy-inheritance.i18n.yaml | 4 +- .../2026-07-25-subagent-policy-inheritance.md | 12 ++-- ...26-07-25-subagent-policy-inheritance.zh.md | 12 ++-- ...able-subagent-policy-inheritance.i18n.yaml | 4 +- ...continuable-subagent-policy-inheritance.md | 4 +- ...tinuable-subagent-policy-inheritance.zh.md | 4 +- ...0-subagent-approval-pinned-never.i18n.yaml | 6 ++ ...26-08-10-subagent-approval-pinned-never.md | 34 +++++++++ ...08-10-subagent-approval-pinned-never.zh.md | 34 +++++++++ .../advanced-toolchain/session.1.jsonl | 37 +++++----- .../advanced-toolchain/session.2.jsonl | 37 +++++----- .../advanced-toolchain/session.jsonl | 2 +- .../session.1.jsonl | 57 +++++++-------- .../session.jsonl | 2 +- .../session.1.jsonl | 35 ++++----- .../session.jsonl | 2 +- .../subagent-continuable/session.1.jsonl | 69 +++++++++--------- .../subagent-continuable/session.jsonl | 2 +- .../session.1.jsonl | 57 +++++++-------- .../session.2.jsonl | 57 +++++++-------- .../session.jsonl | 2 +- .../snapshots/subagent-fork/session.1.jsonl | 38 +++++----- .../subagent-list-agents/session.1.jsonl | 35 ++++----- .../subagent-list-agents/session.jsonl | 2 +- .../snapshots/subagent-mixed/session.1.jsonl | 43 +++++------ .../snapshots/subagent-mixed/session.2.jsonl | 40 ++++++----- .../snapshots/subagent-mixed/session.jsonl | 2 +- .../snapshots/subagent-multi/session.1.jsonl | 43 +++++------ .../snapshots/subagent-multi/session.2.jsonl | 45 ++++++------ .../snapshots/subagent-multi/session.jsonl | 2 +- .../session.1.jsonl | 2 + .../snapshots/subagent-report/session.1.jsonl | 55 +++++++------- .../snapshots/subagent-report/session.jsonl | 2 +- .../snapshots/subagent-spawn/session.1.jsonl | 43 +++++------ .../snapshots/subagent-spawn/session.jsonl | 2 +- .../snapshots/workflow-run/session.1.jsonl | 43 +++++------ .../snapshots/workflow-run/session.jsonl | 2 +- .../advanced-toolchain/session.1.jsonl | 27 +++---- .../advanced-toolchain/session.2.jsonl | 27 +++---- .../advanced-toolchain/session.jsonl | 28 ++++---- .../parent-override/child.expected.jsonl | 2 +- .../notifications.expected.jsonl | 63 ++++++++-------- .../snapshots/subagent-spawn/session.1.jsonl | 29 ++++---- .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../tests/inheritance.spec.ts | 71 ++++++++++++++++--- .../tests/structured.spec.ts | 5 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 22 ++++-- packages/subagent/subagent/README.zh.md | 22 ++++-- packages/subagent/subagent/src/child-agent.ts | 69 ++++++++++++------ .../subagent/subagent/src/continuation.ts | 12 ++-- .../tests/continuation-inheritance.spec.ts | 49 +++++++++---- .../subagent/tests/continuation.spec.ts | 4 +- .../tests/tool-subagent-control.spec.ts | 4 +- .../tests/tool-subagent-report.spec.ts | 4 +- .../verify-package-readme-model-experience.ts | 1 - 61 files changed, 781 insertions(+), 550 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md create mode 100644 .agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md create mode 100644 examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.1.jsonl diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml index c1ba01b255..ea386ac60b 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-approval-seam.md -2026-07-06-approval-seam.md: 7c830d93f19a40ab193cfebabca854882ab68d62 -2026-07-06-approval-seam.zh.md: 9dedfddadc23b0da44b28e8750508653ee20bb83 +2026-07-06-approval-seam.md: 8aa9986139dae77e08c166b72545bfa688a389e0 +2026-07-06-approval-seam.zh.md: ef4ccf5fd2b54888a648737866ff6f5fe1678882 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md index 7c830d93f1..8aa9986139 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md @@ -123,7 +123,7 @@ Costs and accepted limits: - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. - **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. -- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. A `'never'` parent seeds that override into each in-process child's log ([decision](2026-07-25-subagent-policy-inheritance.md)), so the child is told up front instead of asking into the empty waterfall. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred). +- **How do subagents' approvals route?** They do not: delegation pins every in-process child to `'never'` ([approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md)), so each child ask resolves `rejected` before any answerer and the child is told up front through its runtime context. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred). - **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the next atomic runtime-context snapshot states the policy; each successful auto-rejection records the audit pair. - **What happens across a hot reload, or when an answerer unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does a client get approval context?** The request carries the exact `callId` and the asker's human-readable `reason`; channel adapters may correlate richer tool-call state without duplicating arguments in the approval seam. diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md index 9dedfddadc..ef4ccf5fd2 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md @@ -123,7 +123,7 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 - **谁决定一次调用是否需要 ask?** 策略生产者:返回 `permissionDecision: ask` 的钩子、任何 `tools/pre-execute` 监听器、或沙箱升级门禁。seam 和桥只负责路由和应答;二者都不注入自己对「什么值得弹出提示」的判断。 - **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled` 并携带自己的拒绝文本。已中止的 signal 直接结算为 `cancelled` 而不派发;ask 进行中的中止丢弃迟到的应答。当两个审计追加都提交时,任一路径都记录恰好一对事件,绝不会两对。 - **如果客户端以 harness 从未提供的选项应答呢?** 除已提供的 `allow_once` 之外的任何选项都映射为 `rejected`——来自不合规客户端的未知 optionId 永远不能授权。 -- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`'never'` 父级会把该覆盖项预置到每个进程内子 agent 的日志中([决策](2026-07-25-subagent-policy-inheritance.md)),因此子 agent 一开始就会得知,而不是向空的 waterfall 发出 ask。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。 +- **subagent 的审批如何路由?** 不路由:委派会把每个进程内子 agent 钉定为 `'never'`([审批钉定决策](2026-08-10-subagent-approval-pinned-never.md)),因此子 agent 的每次 ask 都在任何应答者之前解析为 `rejected`,子 agent 则通过其运行时上下文一开始就会得知。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。 - **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);下一份原子化的运行时上下文快照会声明该策略;每次成功的自动拒绝都会记录审计对。 - **热重载或应答者在会话中途卸载时会发生什么?** 应答者随其拥有的 fiber 一起 dispose,因此下一次 ask 降级为 `unavailable` 而非挂在死通道上;重新挂载会重新注册应答者,无需追赶状态。 - **客户端从哪里获得审批上下文?** 请求携带精确的 `callId` 和发起方的人类可读 `reason`;通道适配器可自行关联更丰富的工具调用状态,而无需在审批 seam 中重复携带参数。 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index 48074dd7bf..dbfaaad95a 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md -2026-07-25-subagent-policy-inheritance.md: 910581a595f48b356eea9c6242a06159c52b3854 -2026-07-25-subagent-policy-inheritance.zh.md: a0edb3c6beb59a9fe8fdfb801ee718f7c034c296 +2026-07-25-subagent-policy-inheritance.md: 34751a4e29e48c84d37425857b8b1b56c8d866eb +2026-07-25-subagent-policy-inheritance.zh.md: 5fa8edf04ed63da9b2e1b9a062ca2f649c8c96fb diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index 910581a595..34751a4e29 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -1,4 +1,4 @@ -# Agent Note: In-process subagent policy inheritance — the child starts under the parent's sandbox and approval overrides +# Agent Note: In-process subagent policy inheritance — the child starts under the parent's sandbox override Status: implemented @@ -6,11 +6,11 @@ English | [中文](2026-07-25-subagent-policy-inheritance.zh.md) ## Problem -Sandbox and approval overrides are per-session log folds. An in-process subagent gets a new session, so a spawn child once fell back to deployment defaults and a fork child saw only switches inside its completed-turn prefix. Delegation could therefore widen a parent that had switched to `read-only`, or turn a parent's unattended `'never'` approval stance back into prompting behavior. +Sandbox and approval overrides are per-session log folds. An in-process subagent gets a new session, so a spawn child once fell back to deployment defaults and a fork child saw only switches inside its completed-turn prefix. Delegation could therefore widen a parent that had switched to `read-only`. ## Decision -The delegation boundary snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` before its first await, through the shared child-agent helpers (`captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides` in `dsh-subagent`), which the one-shot driver and the [continuable start](2026-08-10-continuable-subagent-policy-inheritance.md) both call. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. Both services are optional, and only explicit session overrides are copied, never deployment defaults or one-shot grants. +The delegation boundary snapshots `sandboxPolicy.overrideOf(parent.session)` before its first await, through the shared child-agent helpers (`captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides` in `dsh-subagent`), which the one-shot driver and the [continuable start](2026-08-10-continuable-subagent-policy-inheritance.md) both call. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. The sandbox-policy service is optional, and only the explicit session override is copied, never deployment defaults or one-shot grants. The approval policy is not inherited: the same capture pins every child to `'never'` — the [approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md) supersedes this note's original approval-override inheritance. Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event appended during the child factory's unpublished setup. The session constructor has already fixed `Session.firstLiveSeq` at the fork-prefix length, so the inherited facts follow fork history, reach telemetry when the child is announced, and leave `SessionHeader.seedLength` at the prefix length. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's logged state, so the rule composes without another inheritance mechanism. @@ -18,7 +18,7 @@ Ordinary session appends validate the inherited events before publication, and p ### What a blocked child experiences -A confined child gets the ordinary denial marker. No answerer currently owns an in-process child, so an escalation request fails closed and the child reports upward; a controller-owned parent may widen its own session and delegate again. An inherited `'never'` policy tells the child not to request escalation in its first system prompt. +A confined child gets the ordinary denial marker, and an escalation request is rejected deterministically by the child's pinned `'never'` policy; the `subagent:delegation` runtime-context statement tells the child to report the limitation instead of retrying, and a controller-owned parent may widen its own session and delegate again ([approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md)). ## Alternatives considered @@ -27,10 +27,10 @@ A confined child gets the ordinary denial marker. No answerer currently owns an - **A first-prompt listener** — rejected: it introduces listener ordering and a later timing boundary even though the creation transaction already permits log appends before publication. - **Copying deployment defaults** — rejected: defaults remain operator-owned and may change; an unswitched parent stamps nothing, so its child follows the current deployment. - **Live resolution walking `parentSession` at each call** — rejected: it breaks the "two sessions never see each other's state" isolation invariant, requires the parent session to stay loaded for the child's lifetime, and makes a mid-run parent switch retroactively change a running child. Snapshot-at-delegation is the semantic: the child keeps the policy it was handed; cancel-and-respawn picks up a tightening. -- **Forcing `'never'` or routing asks to the root controller** — rejected as inheritance behavior. A forced value forecloses a future child answerer; parent routing needs parent-chain ownership and the spawning `callId`, and remains deferred in [the approval-seam Agent Note](2026-07-06-approval-seam.md). +- **Forcing `'never'`** — originally rejected here as inheritance behavior because a forced value forecloses a future child answerer; that verdict is reversed by the [approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md), which owns the current rationale. Routing asks to the root controller needs parent-chain ownership and the spawning `callId`, and remains deferred in [the approval-seam Agent Note](2026-07-06-approval-seam.md). ## Consequences -- Spawn, fork, and nested in-process children retain a parent's explicit sandbox and approval overrides. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, the live-event boundary, default omission, and context disposal. +- Spawn, fork, and nested in-process children retain a parent's explicit sandbox override and are pinned to `'never'` approvals. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, the live-event boundary, default omission, and context disposal. - The keyless headless snapshot is the assembled regression: only the parent is `read-only`, the deployment default is `workspace-write`, and the child's persisted event plus denied disk write both fail if capture is removed. - Each delegation adds at most two log-only events. `dsh-subagent` owns the optional peer types for the two policy services — its shared helpers hold the `ctx.get` consumption; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index a0edb3c6be..5fa8edf04e 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 进程内 subagent 策略继承——子 agent 在父级的沙箱与审批覆盖项下启动 +# Agent Note: 进程内 subagent 策略继承——子 agent 在父级的沙箱覆盖项下启动 Status: implemented @@ -6,11 +6,11 @@ Status: implemented ## 问题 -沙箱与审批覆盖项都是按会话的日志折叠。进程内 subagent 会获得一个新会话,因此 spawn 子 agent(智能体)过去会回退到部署默认值,fork 子 agent 则只能看到其已完成轮次前缀中的切换。因此,委派可能放宽已经切换到 `read-only` 的父级,或让父级无人值守的 `'never'` 审批立场重新变成会发起提示的行为。 +沙箱与审批覆盖项都是按会话的日志折叠。进程内 subagent 会获得一个新会话,因此 spawn 子 agent(智能体)过去会回退到部署默认值,fork 子 agent 则只能看到其已完成轮次前缀中的切换。因此,委派可能放宽已经切换到 `read-only` 的父级。 ## 决策 -委派边界在第一次 await 之前,经由共享的子 agent 辅助函数(`dsh-subagent` 中的 `captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides`)对 `sandboxPolicy.overrideOf(parent.session)` 和 `approval.overrideOf(parent.session)` 获取快照;一次性驱动器与[可继续启动](2026-08-10-continuable-subagent-policy-inheritance.md)都会调用这些辅助函数。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。这两个服务均为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。 +委派边界在第一次 await 之前,经由共享的子 agent 辅助函数(`dsh-subagent` 中的 `captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides`)对 `sandboxPolicy.overrideOf(parent.session)` 获取快照;一次性驱动器与[可继续启动](2026-08-10-continuable-subagent-policy-inheritance.md)都会调用这些辅助函数。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。沙箱策略服务为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。审批策略不继承:同一次捕获会把每个子 agent 钉定为 `'never'`——[审批钉定决策](2026-08-10-subagent-approval-pinned-never.md)取代了本 note 原先的审批覆盖项继承。 每个捕获值都会成为子 agent 工厂在未发布设置阶段追加的一条带来源标记的 `sandbox/mode` 或 `approval/policy` 事件。会话构造函数已将 `Session.firstLiveSeq` 固定为 fork 前缀的长度,因此继承事实会排在 fork 历史之后,在子 agent 公布时进入遥测,同时让 `SessionHeader.seedLength` 保持为此前缀的长度。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已记录的状态,因此无需另一套继承机制即可组合此规则。 @@ -18,7 +18,7 @@ Status: implemented ### 被拦住的子 agent 会经历什么 -受限子 agent 会得到普通拒绝标记。目前没有应答器认领进程内子 agent,因此升级请求会以拒绝方式失败,由子 agent 向上汇报;由控制器持有的父 agent 可以放宽自己的会话后重新委派。继承的 `'never'` 策略会在第一份系统提示词中告知子 agent 不要请求升级。 +受限子 agent 会得到普通拒绝标记,升级请求则被子 agent 钉定的 `'never'` 策略确定性拒绝;`subagent:delegation` 运行时上下文声明告知子 agent 上报限制而不是重试,由控制器持有的父 agent 可以放宽自己的会话后重新委派([审批钉定决策](2026-08-10-subagent-approval-pinned-never.md))。 ## 考虑过的替代方案 @@ -27,10 +27,10 @@ Status: implemented - **首个提示词监听器**:不予采纳。尽管创建事务已经允许在发布前追加日志,它仍会引入监听器顺序与更晚的时序边界。 - **复制部署默认值**:不予采纳。默认值仍由运维人员拥有且可能变化;未切换的父级不会记录任何值,因此其子 agent 跟随当前部署。 - **每次调用时沿 `parentSession` 实时解析**:不予采纳。这会打破「两个会话永远看不到彼此状态」的隔离不变量,要求父会话在子 agent 的整个生命周期内保持加载,还会让父级在子 agent 运行途中做的切换追溯性地改变一个正在运行的子 agent。委派时快照才是本设计的语义:子 agent 保持它被交付时的策略;取消后重新 spawn 即可拿到收紧后的策略。 -- **强制使用 `'never'` 或把 ask 路由到根控制器**:不作为继承行为采纳。强制值会排除未来的子 agent 应答器;父级路由需要父链所有权与发起 spawn 的 `callId`,仍按[审批 seam Agent Note](2026-07-06-approval-seam.md) 所述延期。 +- **强制使用 `'never'`**:本 note 当初不作为继承行为采纳,理由是强制值会排除未来的子 agent 应答器;该结论已被[审批钉定决策](2026-08-10-subagent-approval-pinned-never.md)推翻,现行理由归其所有。把 ask 路由到根控制器需要父链所有权与发起 spawn 的 `callId`,仍按[审批 seam Agent Note](2026-07-06-approval-seam.md) 所述延期。 ## 后果 -- spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱与审批覆盖项。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、实时事件边界、默认值省略与上下文释放。 +- spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱覆盖项,并被钉定为 `'never'` 审批。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、实时事件边界、默认值省略与上下文释放。 - 无密钥 headless 快照是组装后应用层面的回归测试:只有父级是 `read-only`,部署默认值是 `workspace-write`;若移除捕获,子 agent 的持久化事件与被拒的磁盘写入这两项检查都会失败。 - 每次委派最多增加两条仅日志事件。两个策略服务的可选 peer 类型由 `dsh-subagent` 拥有——其共享辅助函数持有 `ctx.get` 消费;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。 diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml index 54bc9adfb4..ac90a1c70d 100644 --- a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md -2026-08-10-continuable-subagent-policy-inheritance.md: 04bcd0329a4608445b672d75b6c1e56dd265b25a -2026-08-10-continuable-subagent-policy-inheritance.zh.md: 9ef457df814ed848b04f42c5891024a0890bc9dd +2026-08-10-continuable-subagent-policy-inheritance.md: c9b75f2840eb2f124f040d138b761ee145fc6f83 +2026-08-10-continuable-subagent-policy-inheritance.zh.md: 8bd7f68c578ed827c756a415f017eb8cb61e5721 diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md index 04bcd0329a..c9b75f2840 100644 --- a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md @@ -10,7 +10,7 @@ The one-shot in-process driver has seeded parent sandbox/approval overrides into ## Decision -The capture/append pair moved from the one-shot driver into the seam's shared child-agent module (`dsh-subagent/src/child-agent.ts`), the declared one home for shared child composition: `captureDelegatedPolicyOverrides(parent)` snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` through optional `ctx.get`, and `appendDelegatedPolicyOverrides(childSession, overrides)` appends the `source: 'delegation'` events. The one-shot driver and the continuation manager both call them, so the two paths cannot drift. +The capture/append pair moved from the one-shot driver into the seam's shared child-agent module (`dsh-subagent/src/child-agent.ts`), the declared one home for shared child composition: `captureDelegatedPolicyOverrides(parent)` snapshots `sandboxPolicy.overrideOf(parent.session)` through optional `ctx.get` and pins the child approval policy to `'never'` ([approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md)), and `appendDelegatedPolicyOverrides(childSession, overrides)` appends the `source: 'delegation'` events. The one-shot driver and the continuation manager both call them, so the two paths cannot drift. `startContinuable` captures before its first await (`prepareContinuable`), the same "a later parent switch belongs to the parent's future" boundary as one-shot. The snapshot travels in `MaterializeInputs.create`, so only fresh materialization appends the events during unpublished setup, after any fork seed. A cold resume passes no `create` inputs and appends nothing: the persisted child log already carries the delegation events, and replaying the log IS the state. The durable child log — not the current Activation, not the resuming parent — owns the child's effective policy, so a parent switch between residency epochs never retroactively changes a durable child. @@ -23,7 +23,7 @@ The capture/append pair moved from the one-shot driver into the seam's shared ch ## Consequences -- Default-bundle background delegation (`backgroundMode: continuable`) now inherits a parent's explicit sandbox and approval overrides; compositions without either policy service behave unchanged. +- Default-bundle background delegation (`backgroundMode: continuable`) now inherits a parent's explicit sandbox override and pins the child to `'never'` approvals; compositions without either policy service behave unchanged. - `dsh-subagent` gains optional peer types on `dsh-sandbox-policy` and `dsh-user-approval` (the `ctx.get` pattern the one-shot driver used); `dsh-subagent-inprocess` drops its policy-service peers and type imports entirely and delegates to the shared helpers. - The continuable suite (`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`) pins fresh-start seeding, pre-await capture, default omission, cold-resume snapshot stability, and fork-seed precedence; the ACP snapshot scenario `subagent-continuable-inheritance` pins the child's delegation event and read-only runtime context through the assembled app and fails when the capture is removed. - Out-of-process providers (`acp`, `dsh-sdk`, `claude-code`, `codex`) support no continuable children (`prepareContinuable` absent), and their one-shot children keep their own deployment policy (`inheritsParentContext = false`); cross-process policy propagation remains out of scope. diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md index 9ef457df81..8bd7f68c57 100644 --- a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -捕获/追加这对函数从一次性驱动器移入该 seam 的共享子 agent 模块(`dsh-subagent/src/child-agent.ts`),即声明的共享子级组合唯一归属之处:`captureDelegatedPolicyOverrides(parent)` 通过可选的 `ctx.get` 对 `sandboxPolicy.overrideOf(parent.session)` 与 `approval.overrideOf(parent.session)` 建立快照,`appendDelegatedPolicyOverrides(childSession, overrides)` 则追加 `source: 'delegation'` 事件。一次性驱动器与继续执行管理器都调用它们,因此两条路径不会出现偏差。 +捕获/追加这对函数从一次性驱动器移入该 seam 的共享子 agent 模块(`dsh-subagent/src/child-agent.ts`),即声明的共享子级组合唯一归属之处:`captureDelegatedPolicyOverrides(parent)` 通过可选的 `ctx.get` 对 `sandboxPolicy.overrideOf(parent.session)` 建立快照,并把子级审批策略钉定为 `'never'`([审批钉定决策](2026-08-10-subagent-approval-pinned-never.md)),`appendDelegatedPolicyOverrides(childSession, overrides)` 则追加 `source: 'delegation'` 事件。一次性驱动器与继续执行管理器都调用它们,因此两条路径不会出现偏差。 `startContinuable` 在其第一次 await(`prepareContinuable`)之前完成捕获,沿用与一次性路径相同的「父级后续切换属于父级的未来」边界。快照放在 `MaterializeInputs.create` 中传递,因此只有全新物化会在未发布的设置阶段、排在任何 fork 种子之后追加这些事件。冷恢复(cold resume)不传入 `create` 输入,也不追加任何内容:持久化的子日志已经携带委派事件,而回放该日志本身就是状态。子 agent 的生效策略由持久化子日志拥有,而不是当前 Activation,也不是发起恢复的父级,因此父级在驻留纪元(residency epoch)之间的切换绝不会追溯性地改变一个持久化子 agent。 @@ -23,7 +23,7 @@ Status: implemented ## 后果 -- 默认组合包的后台委派(`backgroundMode: continuable`)现在会继承父级显式的沙箱与审批覆盖项;未组合任一策略服务的组合保持原有行为。 +- 默认组合包的后台委派(`backgroundMode: continuable`)现在会继承父级显式的沙箱覆盖项,并把子级钉定为 `'never'` 审批;未组合任一策略服务的组合保持原有行为。 - `dsh-subagent` 新增针对 `dsh-sandbox-policy` 与 `dsh-user-approval` 的可选 peer 类型(即一次性驱动器所用的 `ctx.get` 模式);`dsh-subagent-inprocess` 完全移除自己的策略服务 peer 与类型导入,委托给共享辅助函数。 - 可继续测试套件(`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`)锁定全新启动的种子写入、await 前捕获、默认值省略、冷恢复快照稳定性与 fork 种子优先级;ACP 快照场景 `subagent-continuable-inheritance` 经组装后的应用锁定子级的委派事件与只读运行时上下文,移除捕获时即失败。 - 进程外提供方(`acp`、`dsh-sdk`、`claude-code`、`codex`)不支持可继续子 agent(没有 `prepareContinuable`),其一次性子 agent 保留自身的部署策略(`inheritsParentContext = false`);跨进程策略传播仍不在范围内。 diff --git a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml new file mode 100644 index 0000000000..cde23b2552 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md +2026-08-10-subagent-approval-pinned-never.md: 578dbe58cd4e0a3c97552e20f27a2818ee9c9f40 +2026-08-10-subagent-approval-pinned-never.zh.md: 45bc461505b07a4c10e063a122e8003f52afd259 diff --git a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md new file mode 100644 index 0000000000..578dbe58cd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md @@ -0,0 +1,34 @@ +# Agent Note: Delegated subagents run with approvals pinned to `'never'` + +Status: implemented + +English | [中文](2026-08-10-subagent-approval-pinned-never.zh.md) + +## Problem + +A delegated child that asked for approval had no one to ask. Under an interactive parent (`'ask'`), a background child's escalation became a pending question no product surface showed — subagent sessions are omitted from the Web sidebar, the parent's `list_agents` reports plain `running`/`idle`, and the catalog rows show only activity — so a permission-blocked child was indistinguishable from a working one; headless and unanswered compositions failed the same ask closed as `'unavailable'`. The rejection audit landed only in the child's own log, and no tool parameter or Web control can adjust a running child session's sandbox mode or approval policy ([deepseek-harness#1723](https://github.com/deepseek-harness/deepseek-harness/issues/1723)). The mechanism-heavy fix — a durable blocked-state projection, parent notices, catalog badges, and a permission write path through the subagent ownership fence — was disproportionate directly before release. + +## Decision + +A delegated child acts only within the permission scope fixed at delegation, and approval prompts are removed from its world entirely: `captureDelegatedPolicyOverrides(parent)` (`dsh-subagent/src/child-agent.ts`) still snapshots the parent session's explicit sandbox override, but pins `approvalPolicy: 'never'` whenever the approval capability is composed — it no longer reads the parent's own approval policy. `appendDelegatedPolicyOverrides()` writes the pin as the durable `approval/policy { policy: 'never', source: 'delegation' }` event on the child's log, through the same one-shot and continuable delegation paths as the sandbox snapshot, so cold resume replays it and a fork seed's stale parent policy loses to it. + +Enforcement is the existing `ApprovalService` `'never'` semantics at the one operation that decides asks: every child ask — a `sandbox_permissions` escalation from bash or fs, a hook-driven permission question, any future asker — resolves `'rejected'` deterministically before any answerer is consulted, still leaving the `approval/asked`/`approval/decided` audit pair on the child log. The child's whole permission story is therefore its sandbox scope: a `danger-full-access` parent delegates children that need no approvals, a `read-only` parent delegates children with no escape hatch, and a widening decision always belongs to the parent side (widen the parent session, then delegate or follow up again). + +Every in-process child is told, not trapped: `applyChildComposition` registers the scoped `subagent:delegation` runtime-context statement (order 120, after the `sandbox:policy` and `approval:policy` sentences) stating that the scope was fixed at start, approval-requiring operations are rejected automatically, and a task needing wider access ends with a reported limitation instead of retries. The statement is a runtime-context contribution rather than a system-prompt section, so the deployment's system prompt stays uniform across parents and children (the snapshot suite pins that uniformity) and the fact rides the same durable snapshot as the policy sentences. + +This supersedes the approval half of the [in-process delegation-policy decision](2026-07-25-subagent-policy-inheritance.md) and reverses its "forcing `'never'` forecloses a future child answerer" verdict: approval inheritance shipped, produced the invisible blocked states above, and a future child answerer now requires reversing this note first. + +## Alternatives considered + +- **Inheriting the parent's approval override** (the prior behavior) — rejected: only a parent already at `'never'` produced deterministic children; an interactive parent seeded children whose asks waited on a prompt no one was watching or failed closed `'unavailable'`, and the outcome depended on which surfaces happened to be attached. +- **Blocked-state visibility and per-child permission adjustment** (the original #1723 acceptance) — deferred, not rejected: a `list_agents` blocked annotation, parent notices over the settlement-delivery seam, catalog badges, and a subagent-routed permission channel remain the richer design, but each needs its own seam work and none is required once children cannot enter a blocked-waiting state. +- **Routing child asks to the parent controller** — still deferred in the [approval-seam Agent Note](2026-07-06-approval-seam.md): it needs parent-chain ownership and the spawning `callId`. +- **Pinning inside `ApprovalService` by session origin** — rejected: it couples the approval package to delegation vocabulary and duplicates a decision the delegation boundary already owns; the delegation-seeded event is enforceable because no current write path can switch a child session's policy (the `/permission` command requires generic Host routing, which the subagent ownership fence denies to child sessions). + +## Consequences + +- The child's sandbox inheritance is the complete delegation permission model; the `DelegatedPolicyOverrides.approvalPolicy` field narrows to `'never' | undefined` (`undefined` only without a composed approval capability). +- Model-visible: each child's runtime-context snapshot carries the `subagent:delegation` statement plus the standing disabled-approvals sentence; parent requests are unchanged. The executor-boundary test proves a child escalation is rejected without consulting a root answerer that would have granted it, with the audit pair logged. +- Boundaries: in-process one-shot, continuable, and workflow-spawned children are enforced through the shared helpers; `subagent-acp` children keep that provider's explicit machine `permission` policy; `claude-code`, `codex`, and `dsh-sdk` children run in external processes under their own composition. +- Children persisted before the pin fold to the deployment approval default on cold resume; pre-release, no migration is added. +- Snapshot fixtures record the pin: every in-process child log gains the delegation `approval/policy` event, and `subagent-published-run-failure` now persists a one-event child log where the child previously left no durable events. diff --git a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md new file mode 100644 index 0000000000..45bc461505 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 被委派的 subagent 以钉定为 `'never'` 的审批策略运行 + +Status: implemented + +[English](2026-08-10-subagent-approval-pinned-never.md) | 中文 + +## 问题 + +被委派的子 agent 发起审批请求时无人可问。在交互式父级(`'ask'`)之下,后台子 agent 的升级请求会变成一个任何产品界面都不展示的挂起问题——subagent 会话不进入 Web 侧边栏,父级的 `list_agents` 只报告普通的 `running`/`idle`,目录树的行也只显示活动状态——因此被权限拦住的子 agent 与正常干活的子 agent 无法区分;headless 与无应答者的组合则让同一次 ask 以 `'unavailable'` 失败关闭。拒绝的审计记录只落在子 agent 自己的日志里,而且没有任何工具参数或 Web 控件能调整一个正在运行的子会话的沙箱模式或审批策略([deepseek-harness#1723](https://github.com/deepseek-harness/deepseek-harness/issues/1723))。机制繁重的修复方案——持久化的受阻状态投影、父级通知、目录树徽标,以及穿过 subagent 所有权围栏的权限写入路径——在临近发布时代价不成比例。 + +## 决策 + +被委派的子 agent 只在委派时固定的权限范围内行动,审批提示则从它的世界中彻底移除:`captureDelegatedPolicyOverrides(parent)`(`dsh-subagent/src/child-agent.ts`)仍对父会话的显式沙箱覆盖项建立快照,但只要审批能力已组合,就把 `approvalPolicy: 'never'` 钉定下来——不再读取父级自身的审批策略。`appendDelegatedPolicyOverrides()` 把这个钉定作为持久化的 `approval/policy { policy: 'never', source: 'delegation' }` 事件写入子 agent 的日志,与沙箱快照走完全相同的一次性与可继续委派路径,因此冷恢复会重放它,fork 种子中陈旧的父级策略也会输给它。 + +强制执行沿用既有的 `ApprovalService` `'never'` 语义,落在裁决 ask 的唯一操作上:子 agent 的每次 ask——bash 或 fs 的 `sandbox_permissions` 升级、hook 驱动的权限询问、任何未来的请求方——都在咨询任何应答者之前确定性地解析为 `'rejected'`,同时仍在子日志上留下 `approval/asked`/`approval/decided` 审计对。子 agent 的全部权限故事因此就是它的沙箱范围:`danger-full-access` 父级委派出的子 agent 无需任何审批,`read-only` 父级委派出的子 agent 没有任何逃生通道,而放宽的决定始终属于父级一侧(先放宽父会话,再重新委派或继续 follow-up)。 + +每个进程内子 agent 都被告知而非被困住:`applyChildComposition` 注册作用域内的 `subagent:delegation` 运行时上下文声明(order 120,位于 `sandbox:policy` 与 `approval:policy` 语句之后),声明权限范围已在启动时固定、需要审批的操作会被自动拒绝、需要更宽访问的任务应以上报限制收尾而不是重试。该声明是运行时上下文贡献而非系统提示词 section,因此部署的系统提示词在父子之间保持统一(快照测试套件钉住了这一统一性),该事实也随策略语句乘坐同一份持久化快照。 + +本决策取代[进程内委派策略决策](2026-07-25-subagent-policy-inheritance.md)中的审批一半,并推翻其「强制 `'never'` 会排除未来的子 agent 应答器」的结论:审批继承已经落地,产生的正是上述不可见的受阻状态;未来若要引入子 agent 应答器,必须先推翻本 note。 + +## 考虑过的替代方案 + +- **继承父级的审批覆盖项**(先前的行为):不予采纳。只有已处于 `'never'` 的父级才产生确定性的子 agent;交互式父级种出的子 agent,其 ask 要么等待一个无人在看的提示,要么以 `'unavailable'` 失败关闭,结果取决于当时恰好接入了哪些界面。 +- **受阻状态可见性与逐子级权限调整**(#1723 原有的验收):延后而非否决。`list_agents` 的受阻标注、经由结算投递 seam 的父级通知、目录树徽标,以及 subagent 专用的权限通道仍是更完整的设计,但每一项都需要独立的 seam 工作;一旦子 agent 不可能进入等待审批的受阻状态,这些都不再是必需。 +- **把子 agent 的 ask 路由到父控制器**:仍按[审批 seam Agent Note](2026-07-06-approval-seam.md) 延后。它需要父链所有权与发起 spawn 的 `callId`。 +- **在 `ApprovalService` 内按会话来源钉定**:不予采纳。这会让审批包耦合委派词汇,并重复一个委派边界已经拥有的决定;委派种入的事件之所以可强制执行,是因为当前不存在任何能切换子会话策略的写入路径(`/permission` 命令要求通用 Host 路由,而 subagent 所有权围栏对子会话拒绝该路由)。 + +## 后果 + +- 子 agent 的沙箱继承就是委派权限模型的全部;`DelegatedPolicyOverrides.approvalPolicy` 字段收窄为 `'never' | undefined`(仅在未组合审批能力时为 `undefined`)。 +- 模型可见:每个子 agent 的运行时上下文快照携带 `subagent:delegation` 声明以及固定的审批已禁用语句;父级请求不变。executor 边界测试证明:即使根部有一个本会批准的应答者,子 agent 的升级仍被拒绝且不咨询该应答者,审计对照常落日志。 +- 边界:进程内一次性、可继续以及 workflow 派生的子 agent 都经由共享辅助函数强制执行;`subagent-acp` 子 agent 保留该提供方显式的机器 `permission` 策略;`claude-code`、`codex` 与 `dsh-sdk` 子 agent 运行在外部进程中,由各自的组合决定。 +- 在钉定之前持久化的子 agent 冷恢复时折叠到部署审批默认值;处于预发布阶段,不添加迁移。 +- 快照夹具记录了该钉定:每个进程内子日志都新增委派 `approval/policy` 事件,`subagent-published-run-failure` 现在会持久化一份单事件子日志,而此前该子 agent 不留任何持久化事件。 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 7908f0e71b..66be552da6 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,19 +1,20 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498801881,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"}]}} -{"type":"turn/start","seq":1,"time":1785821418076,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821418076,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821418091,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} -{"type":"step/start","seq":4,"time":1785730458555,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730458555,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730458555,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"f9a2d1b6-8f23-43a5-8702-d413fed40990"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730458555,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730458555,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730458555,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":12,"time":1785498801905,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":13,"time":1785730458561,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":14,"time":1785730458561,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":15,"time":1785730458561,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b9c977ca-2c1a-4a5e-8397-e0b9381a9943"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1785730458561,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":17,"time":1785730458561,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357538290,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357538290,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"}]}} +{"type":"turn/start","seq":2,"time":1786357538290,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357538290,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357538308,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"step/start","seq":5,"time":1786357538310,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730458555,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357538310,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"baed758f-a123-4c3d-8587-a5b7d854f71f"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357538310,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730458555,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730458555,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":12,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":13,"time":1785498801905,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":14,"time":1785730458561,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":15,"time":1785730458561,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":16,"time":1785730458561,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b9c977ca-2c1a-4a5e-8397-e0b9381a9943"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730458561,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":18,"time":1785730458561,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index adf877c24b..a449b90550 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,19 +1,20 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498802039,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"}]}} -{"type":"turn/start","seq":1,"time":1785821418251,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821418251,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821418270,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} -{"type":"step/start","seq":4,"time":1785730458703,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730458703,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730458703,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"dfbcd587-db47-4c3d-bbe9-8c031b215fc3"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730458703,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730458703,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730458703,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":12,"time":1785498802068,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":13,"time":1785730458709,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":14,"time":1785730458709,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":15,"time":1785730458709,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5c33b525-4844-4272-b6f2-e036356d0e22"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1785730458709,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":17,"time":1785730458709,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357538450,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357538450,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"}]}} +{"type":"turn/start","seq":2,"time":1786357538450,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357538450,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357538469,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"step/start","seq":5,"time":1786357538470,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730458703,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357538471,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"01f64214-c832-47ef-8e90-052047edc27d"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357538471,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730458703,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730458703,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":12,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":13,"time":1785498802068,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":14,"time":1785730458709,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":15,"time":1785730458709,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":16,"time":1785730458709,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5c33b525-4844-4272-b6f2-e036356d0e22"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730458709,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":18,"time":1785730458709,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index d6935b6c98..aea9e3107c 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821417919,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498801761,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"},"role":"user","id":"6e45782a-31be-4ba7-8c4a-7411a2027e36"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730458430,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"9f38e2b8-1d4e-4c90-8896-00aa42307ea7"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730458430,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"06416873-c855-452d-8996-ea5cf45223d1"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730458430,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498801765,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730458431,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl index 68527f63ac..857dea88b0 100644 --- a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl @@ -1,29 +1,30 @@ {"type":"session","version":0,"id":"55555555-5555-4555-8555-555555555555","createdAt":2001,"cwd":"{{cwd}}","parentSession":"44444444-4444-4444-8444-444444444444","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1786173701247,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"}]}} -{"type":"turn/start","seq":1,"time":1786173701247,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1786173701247,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1786173701270,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check deployment question"}} -{"type":"step/start","seq":4,"time":1786173701272,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1786173701272,"data":{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1786173701272,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"fafefa37-7640-4c80-a00a-6a0c3ce46281"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1786173701272,"data":{"title":"Call ask_user_question once to ask","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1786173701272,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1786173701273,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":11,"time":1786173701278,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_child_question","name":"ask_user_question","argumentsDelta":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}} -{"type":"assistant/chunk","seq":12,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}}} -{"type":"assistant/chunk","seq":13,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":14,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":15,"time":1786173701279,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"301e1969-74b2-45d8-a764-604b806f1c01"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"tool/call","seq":16,"time":1786173701279,"data":{"turn":1,"step":1,"callId":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}} -{"type":"tool/result","seq":17,"time":1786173701292,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_child_question"},"content":[{"type":"tool-result","toolCallId":"call_child_question","content":[{"type":"text","text":"Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result"}],"isError":true}],"role":"user","id":"b9fc0a38-47bb-4335-a8e4-c881ed66bbc3"},"error":{"name":"UserInteractionError","code":"DELEGATED_CALLER"}},"sourceEventSeqs":[16],"surfaceOp":"append"} -{"type":"step/end","seq":18,"time":1786173701292,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":19,"time":1786173701309,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":20,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":21,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}} -{"type":"assistant/chunk","seq":22,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}}} -{"type":"assistant/chunk","seq":23,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}} -{"type":"assistant/chunk","seq":24,"time":1786173701315,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":25,"time":1786173701315,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f2ada85-5967-4ed8-9e16-eaff2af847b5"},"usage":{"inputTokens":10,"outputTokens":4}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":1786173701315,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":27,"time":1786173701315,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357535138,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357535138,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"}]}} +{"type":"turn/start","seq":2,"time":1786357535138,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357535138,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357535155,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check deployment question"}} +{"type":"step/start","seq":5,"time":1786357535158,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1786173701272,"data":{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357535158,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"d8734c8a-d956-4e3f-8d28-399adf51a203"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357535158,"data":{"title":"Call ask_user_question once to ask","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1786173701272,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1786173701273,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":12,"time":1786173701278,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_child_question","name":"ask_user_question","argumentsDelta":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}} +{"type":"assistant/chunk","seq":13,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}}} +{"type":"assistant/chunk","seq":14,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":15,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":16,"time":1786173701279,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"301e1969-74b2-45d8-a764-604b806f1c01"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"tool/call","seq":17,"time":1786173701279,"data":{"turn":1,"step":1,"callId":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}} +{"type":"tool/result","seq":18,"time":1786173701292,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_child_question"},"content":[{"type":"tool-result","toolCallId":"call_child_question","content":[{"type":"text","text":"Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result"}],"isError":true}],"role":"user","id":"b9fc0a38-47bb-4335-a8e4-c881ed66bbc3"},"error":{"name":"UserInteractionError","code":"DELEGATED_CALLER"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1786173701292,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":20,"time":1786173701309,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":21,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}} +{"type":"assistant/chunk","seq":23,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}}} +{"type":"assistant/chunk","seq":24,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}} +{"type":"assistant/chunk","seq":25,"time":1786173701315,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":26,"time":1786173701315,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f2ada85-5967-4ed8-9e16-eaff2af847b5"},"usage":{"inputTokens":10,"outputTokens":4}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1786173701315,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":28,"time":1786173701315,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl index ed155f158f..92efdfa71a 100644 --- a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1786173701175,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1786173701216,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1786173701216,"data":{"content":[{"type":"text","text":"Delegate one question check. Ask the child to call ask_user_question once about the CUDA fallback and return any unresolved question in its final result."}],"source":{"kind":"user"},"role":"user","id":"851bea02-2961-471a-84ec-3b068c451db0"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1786173701217,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"8ef74c46-9e80-475c-9093-0e85ba92e346"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786173701217,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e1f92805-80c9-46b7-94ac-6cdb05d23f86"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1786173701217,"data":{"title":"Delegate one question check. Ask","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1786173701218,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1786173701219,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl index 478198cf0c..bd9557666f 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl @@ -2,20 +2,21 @@ {"type":"subagent/descriptor","seq":0,"time":1786333735890,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","seq":1,"time":1786333735890,"data":{}} {"type":"sandbox/mode","seq":2,"time":1786333735890,"data":{"mode":"read-only","source":"delegation"}} -{"type":"agent/inbox/spliced","seq":3,"time":1786333735891,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"}]}} -{"type":"turn/start","seq":4,"time":1786333735891,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":5,"time":1786333735891,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":6,"time":1786333735916,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":7,"time":1786333735916,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"},"surfaceOp":"append"} -{"type":"user/message","seq":8,"time":1786333735916,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"5f181e93-3fd7-40f3-b5f7-21674b53d7c7"},"surfaceOp":"append"} -{"type":"session/title","seq":9,"time":1786333735916,"data":{"title":"Reply with exactly the word","messageSeqs":[7],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":10,"time":1786333735916,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":11,"time":1786333735916,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":12,"time":1786333735920,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":13,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} -{"type":"assistant/chunk","seq":14,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":15,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":16,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":17,"time":1786333735921,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e2b94008-b067-4ca7-a576-6b4a9060cd83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} -{"type":"step/end","seq":18,"time":1786333735921,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":19,"time":1786333735921,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":3,"time":1786357527742,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":4,"time":1786357527743,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"}]}} +{"type":"turn/start","seq":5,"time":1786357527743,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":6,"time":1786357527743,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":7,"time":1786357527768,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":8,"time":1786333735916,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"},"surfaceOp":"append"} +{"type":"user/message","seq":9,"time":1786357527769,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"1b760052-ffcb-44d2-aae2-fd73d7c444f1"},"surfaceOp":"append"} +{"type":"session/title","seq":10,"time":1786357527769,"data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":11,"time":1786333735916,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":12,"time":1786333735916,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":13,"time":1786333735920,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":14,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":15,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":16,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":17,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":18,"time":1786333735921,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e2b94008-b067-4ca7-a576-6b4a9060cd83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1786333735921,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":20,"time":1786333735921,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl index 0968357f90..15b1748ea5 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl @@ -5,7 +5,7 @@ {"type":"agent/inbox/spliced","seq":3,"time":1786333735845,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":4,"time":1786333735878,"data":{"turn":1,"step":1}} {"type":"user/message","seq":5,"time":1786333735878,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"d554122c-d857-4de0-aea0-6452f260d032"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1786333735878,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"9a793bfc-c155-44f8-ba56-c6843338d6be"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786333735878,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"f931abf5-bb3a-44b4-8fe2-2d06e8766184"},"surfaceOp":"append"} {"type":"session/title","seq":7,"time":1786333735878,"data":{"title":"Follow these steps exactly, then","messageSeqs":[5],"source":{"kind":"fallback"}}} {"type":"request/header","seq":8,"time":1786333735879,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":9,"time":1786333735879,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl index 554de6f448..9352ca6fb9 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -1,37 +1,38 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} {"type":"subagent/descriptor","seq":0,"time":1785544945198,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","seq":1,"time":1785544945198,"data":{}} -{"type":"agent/inbox/spliced","seq":2,"time":1785730451347,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c67a308f-d867-424e-b198-c9f464228703"}]}} -{"type":"turn/start","seq":3,"time":1785821409024,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":4,"time":1785730917162,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"agent/inbox/spliced","seq":5,"time":1785730917192,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"e7d15a94-203d-43ab-8279-4e22d5218feb"}]}} -{"type":"agent/inbox/spliced","seq":6,"time":1785821409076,"data":{"target":"next-turn","start":1,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"755c76db-6ee8-432d-a2d0-f8a3b7914e08"}]}} -{"type":"step/start","seq":7,"time":1785730917198,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":8,"time":1785730917198,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c67a308f-d867-424e-b198-c9f464228703"},"surfaceOp":"append"} -{"type":"user/message","seq":9,"time":1785730917198,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"49acbc16-4d58-460e-8cc0-62838472dce6"},"surfaceOp":"append"} -{"type":"session/title","seq":10,"time":1785730917198,"data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":11,"time":1785730917198,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":12,"time":1785730917199,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":13,"time":1785730696668,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":14,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} -{"type":"assistant/chunk","seq":15,"time":1789000000011,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":16,"time":1789000000012,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":17,"time":1785730451397,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":18,"time":1785730696668,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"178ea526-9e19-49d2-b3b0-57b682320028"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} -{"type":"step/end","seq":19,"time":1785730696668,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":20,"time":1785730696669,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":21,"time":1785821409092,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":22,"time":1785821409092,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":23,"time":1785730696682,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":24,"time":1785730696682,"data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"e7d15a94-203d-43ab-8279-4e22d5218feb"},"surfaceOp":"append"} -{"type":"assistant/chunk","seq":25,"time":1785730696686,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":26,"time":1785730696686,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}}} -{"type":"assistant/chunk","seq":27,"time":1789000000023,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} -{"type":"assistant/chunk","seq":28,"time":1785730451421,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":29,"time":1785730451421,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":30,"time":1785730696686,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ced209bf-5d6d-4880-b187-18cb816a150c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} -{"type":"step/end","seq":31,"time":1785730696686,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":32,"time":1785730696686,"data":{"turn":2,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":33,"time":1785821409110,"data":{"turn":3}} -{"type":"agent/inbox/spliced","seq":34,"time":1785821409110,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"turn/end","seq":35,"time":1785821409122,"data":{"turn":3,"reason":{"kind":"error","error":{"message":"snapshot disk full","code":"UNKNOWN"}}}} +{"type":"approval/policy","seq":2,"time":1786357526242,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357526243,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c67a308f-d867-424e-b198-c9f464228703"}]}} +{"type":"turn/start","seq":4,"time":1786357526243,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":1785730917192,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"agent/inbox/spliced","seq":6,"time":1785821409076,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"e7d15a94-203d-43ab-8279-4e22d5218feb"}]}} +{"type":"agent/inbox/spliced","seq":7,"time":1786357526278,"data":{"target":"next-turn","start":1,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"755c76db-6ee8-432d-a2d0-f8a3b7914e08"}]}} +{"type":"step/start","seq":8,"time":1786357526284,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":9,"time":1785730917198,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c67a308f-d867-424e-b198-c9f464228703"},"surfaceOp":"append"} +{"type":"user/message","seq":10,"time":1786357526284,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"7f1d7407-d9bc-4ec6-ae42-a8767e0e1153"},"surfaceOp":"append"} +{"type":"session/title","seq":11,"time":1786357526284,"data":{"title":"Reply with exactly the word","messageSeqs":[9],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":12,"time":1785730917198,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":13,"time":1785730917199,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":14,"time":1785730696668,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":15,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":16,"time":1789000000011,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":17,"time":1789000000012,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":18,"time":1785730451397,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":19,"time":1785730696668,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"178ea526-9e19-49d2-b3b0-57b682320028"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":1785730696668,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":21,"time":1785730696669,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":22,"time":1785821409092,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":23,"time":1785821409092,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":24,"time":1785730696682,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":25,"time":1785730696682,"data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"e7d15a94-203d-43ab-8279-4e22d5218feb"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":26,"time":1785730696686,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":27,"time":1785730696686,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}}} +{"type":"assistant/chunk","seq":28,"time":1789000000023,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} +{"type":"assistant/chunk","seq":29,"time":1785730451421,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":30,"time":1785730451421,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":31,"time":1785730696686,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ced209bf-5d6d-4880-b187-18cb816a150c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"} +{"type":"step/end","seq":32,"time":1785730696686,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":33,"time":1785730696686,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":34,"time":1785821409110,"data":{"turn":3}} +{"type":"agent/inbox/spliced","seq":35,"time":1785821409110,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"turn/end","seq":36,"time":1785821409122,"data":{"turn":3,"reason":{"kind":"error","error":{"message":"snapshot disk full","code":"UNKNOWN"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl index 898d4b250c..a759c35a32 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821408972,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785730451327,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785730451327,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"125665d3-8c03-4190-b4f9-c27d61d245f4"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730451328,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e7521889-28d8-4434-84b2-21ff0e044fe7"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730451328,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"533e9513-a329-4a36-9a8d-ddaf544b57c3"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730451328,"data":{"title":"Follow these steps exactly, then","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785730451329,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730451329,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl index 8b8b8cc0c2..44d587c851 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl @@ -1,29 +1,30 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1001,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498798860,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"}]}} -{"type":"turn/start","seq":1,"time":1785821414174,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821414174,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821414185,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} -{"type":"step/start","seq":4,"time":1785730456013,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730456014,"data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730456014,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"3244b13c-f211-445f-acf5-fb8d1534537c"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730456014,"data":{"title":"Call subagent once. Ask that","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730456014,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730456014,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_one_child","name":"subagent","argumentsDelta":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}} -{"type":"assistant/chunk","seq":12,"time":1785498798883,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}}} -{"type":"assistant/chunk","seq":13,"time":1785730456018,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":14,"time":1785730456018,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":15,"time":1785730456018,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"21044d12-2e0e-40e3-b47e-4920e21c3e83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"tool/call","seq":16,"time":1785730456019,"data":{"turn":1,"step":1,"callId":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}} -{"type":"tool/result","seq":17,"time":1785730456072,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_one_child"},"content":[{"type":"tool-result","toolCallId":"call_depth_one_child","content":[{"type":"text","text":"DEPTH_REJECTED"}],"isError":false}],"role":"user","id":"aa5451a8-812b-4a51-a52c-dbc5c84f16d0"}},"sourceEventSeqs":[16],"surfaceOp":"append"} -{"type":"step/end","seq":18,"time":1785730456072,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":19,"time":1785730456082,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":20,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":21,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_ONE_DONE"}}} -{"type":"assistant/chunk","seq":22,"time":1785498798949,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_ONE_DONE"}}}} -{"type":"assistant/chunk","seq":23,"time":1785730456086,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":24,"time":1785730456086,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":25,"time":1785730456086,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_ONE_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5a1911c6-f487-458c-b802-4a66221ec046"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":1785730456086,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":27,"time":1785730456086,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357533581,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357533582,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"}]}} +{"type":"turn/start","seq":2,"time":1786357533582,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357533582,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357533600,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} +{"type":"step/start","seq":5,"time":1786357533602,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730456014,"data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357533602,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"82a11d62-da49-4ad3-a243-789ea3cd7c08"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357533602,"data":{"title":"Call subagent once. Ask that","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730456014,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730456014,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_one_child","name":"subagent","argumentsDelta":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}} +{"type":"assistant/chunk","seq":13,"time":1785498798883,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}}} +{"type":"assistant/chunk","seq":14,"time":1785730456018,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":15,"time":1785730456018,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":16,"time":1785730456018,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"21044d12-2e0e-40e3-b47e-4920e21c3e83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"tool/call","seq":17,"time":1785730456019,"data":{"turn":1,"step":1,"callId":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}} +{"type":"tool/result","seq":18,"time":1785730456072,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_one_child"},"content":[{"type":"tool-result","toolCallId":"call_depth_one_child","content":[{"type":"text","text":"DEPTH_REJECTED"}],"isError":false}],"role":"user","id":"aa5451a8-812b-4a51-a52c-dbc5c84f16d0"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1785730456072,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":20,"time":1785730456082,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":21,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_ONE_DONE"}}} +{"type":"assistant/chunk","seq":23,"time":1785498798949,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_ONE_DONE"}}}} +{"type":"assistant/chunk","seq":24,"time":1785730456086,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":25,"time":1785730456086,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":26,"time":1785730456086,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_ONE_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5a1911c6-f487-458c-b802-4a66221ec046"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1785730456086,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":28,"time":1785730456086,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl index 7f2ae89966..89de9b1c4f 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl @@ -1,29 +1,30 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1002,"cwd":"{{cwd}}","parentSession":"22222222-2222-4222-8222-222222222222","origin":"subagent","delegationDepth":2} -{"type":"agent/inbox/spliced","seq":0,"time":1785498798891,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"}]}} -{"type":"turn/start","seq":1,"time":1785821414201,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821414201,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821414214,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} -{"type":"step/start","seq":4,"time":1785730456041,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730456041,"data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730456041,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4a252f7d-8523-433f-a3fc-33812be802ec"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730456041,"data":{"title":"Attempt one subagent call beyond","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730456041,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730456042,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_three_rejected","name":"subagent","argumentsDelta":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}} -{"type":"assistant/chunk","seq":12,"time":1785498798916,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}}} -{"type":"assistant/chunk","seq":13,"time":1785730456047,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":14,"time":1785730456047,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":15,"time":1785730456047,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"467433db-5dbf-42ee-94c0-25c011ce711b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"tool/call","seq":16,"time":1785730456048,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}} -{"type":"tool/result","seq":17,"time":1785730456056,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_three_rejected"},"content":[{"type":"tool-result","toolCallId":"call_depth_three_rejected","content":[{"type":"text","text":"Error: subagent depth 3 exceeds maxDepth 2"}],"isError":true}],"role":"user","id":"9a3d59f3-542a-4400-a62c-be28dcea3bd1"}},"sourceEventSeqs":[16],"surfaceOp":"append"} -{"type":"step/end","seq":18,"time":1785730456056,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":19,"time":1785730456066,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":20,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":21,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_REJECTED"}}} -{"type":"assistant/chunk","seq":22,"time":1785498798937,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_REJECTED"}}}} -{"type":"assistant/chunk","seq":23,"time":1785730456070,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":24,"time":1785730456070,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":25,"time":1785730456070,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_REJECTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"57c0ecaf-3f72-4da9-9eb9-a0726e8f097a"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":1785730456071,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":27,"time":1785730456071,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357533611,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357533611,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"}]}} +{"type":"turn/start","seq":2,"time":1786357533611,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357533611,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357533628,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} +{"type":"step/start","seq":5,"time":1786357533630,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730456041,"data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357533630,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"d5488efe-eea2-4019-8fcb-7e6e49077d8a"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357533630,"data":{"title":"Attempt one subagent call beyond","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730456041,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730456042,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_three_rejected","name":"subagent","argumentsDelta":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}} +{"type":"assistant/chunk","seq":13,"time":1785498798916,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}}} +{"type":"assistant/chunk","seq":14,"time":1785730456047,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":15,"time":1785730456047,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":16,"time":1785730456047,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"467433db-5dbf-42ee-94c0-25c011ce711b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"tool/call","seq":17,"time":1785730456048,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}} +{"type":"tool/result","seq":18,"time":1785730456056,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_three_rejected"},"content":[{"type":"tool-result","toolCallId":"call_depth_three_rejected","content":[{"type":"text","text":"Error: subagent depth 3 exceeds maxDepth 2"}],"isError":true}],"role":"user","id":"9a3d59f3-542a-4400-a62c-be28dcea3bd1"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1785730456056,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":20,"time":1785730456066,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":21,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_REJECTED"}}} +{"type":"assistant/chunk","seq":23,"time":1785498798937,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_REJECTED"}}}} +{"type":"assistant/chunk","seq":24,"time":1785730456070,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":25,"time":1785730456070,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":26,"time":1785730456070,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_REJECTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"57c0ecaf-3f72-4da9-9eb9-a0726e8f097a"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1785730456071,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":28,"time":1785730456071,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl index 6288b6d516..ab699d7d18 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821414127,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1784540790308,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498798839,"data":{"content":[{"type":"text","text":"Delegate through two child generations. The depth-two child must attempt one more subagent call and report the rejection."}],"source":{"kind":"user"},"role":"user","id":"b2260a25-4667-49ed-9297-16b233f22332"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730455980,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"d3ba1b18-4d27-4c90-a95d-125e9ffc9f29"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730455980,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"55365caf-6fcc-484b-a4b7-646914654bbb"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730455980,"data":{"title":"Delegate through two child generations.","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498798841,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730455981,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl index a56f7ccf60..c663606bbd 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl @@ -20,21 +20,23 @@ {"type":"step/end","seq":40,"time":1785730448979,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":41,"time":1785730448979,"data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"session/end-seed","seq":42,"time":1785730449008,"data":{}} -{"type":"agent/inbox/spliced","seq":43,"time":1785498796160,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"}]}} -{"type":"turn/start","seq":44,"time":1785821406523,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":45,"time":1785821406523,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":46,"time":1785821406543,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} -{"type":"step/start","seq":47,"time":1785730449027,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":48,"time":1785730449027,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"},"surfaceOp":"append"} -{"type":"request/header","seq":49,"time":1785730449027,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} -{"type":"assistant/chunk","seq":50,"time":1783352138046,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":51,"time0":1783352138046,"data":{"turn":2,"step":1,"index":0,"dt":[0,28,1,0,0,0,0,28,0,0,0,0,28,28,1,0,0,28,0,0,29,0,0,28,1,28,1,0,0,0,0,30,2],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]}} -{"type":"assistant/chunk","seq":85,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":86,"time0":1783352138307,"data":{"turn":2,"step":1,"index":1,"dt":[1790166963,239266980,117223942],"texts":["M","ARM","AL","ADE"]}} -{"type":"assistant/chunk","seq":90,"time":1785498796192,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}}} -{"type":"assistant/chunk","seq":91,"time":1785498796192,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} -{"type":"assistant/chunk","seq":92,"time":1785730449034,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}} -{"type":"assistant/chunk","seq":93,"time":1785730449034,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":94,"time":1785730449034,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc56e00-c648-4669-92b2-7299e41cb743"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"} -{"type":"step/end","seq":95,"time":1785730449035,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":96,"time":1785730449035,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":43,"time":1786357523264,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":44,"time":1786357523265,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"}]}} +{"type":"turn/start","seq":45,"time":1786357523265,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":46,"time":1786357523265,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":47,"time":1786357523283,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} +{"type":"step/start","seq":48,"time":1786357523286,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":49,"time":1786357523286,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"},"surfaceOp":"append"} +{"type":"user/message","seq":50,"time":1786358035356,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"257e572f-6f95-48f9-b3d7-4ea8b162f374"},"surfaceOp":"append"} +{"type":"request/header","seq":51,"time":1786358035356,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/chunk","seq":52,"time":1783352138046,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":53,"time0":1783352138074,"data":{"turn":2,"step":1,"index":0,"dt":[1,0,0,0,0,28,0,0,0,0,28,28,1,0,0,28,0,0,29,0,0,28,1,28,1,0,0,0,0,30,2,0,0],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]}} +{"type":"assistant/chunk","seq":87,"time":1785142305270,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":88,"time0":1785381572250,"data":{"turn":2,"step":1,"index":1,"dt":[117223942,0,0],"texts":["M","ARM","AL","ADE"]}} +{"type":"assistant/chunk","seq":92,"time":1785730449034,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}}} +{"type":"assistant/chunk","seq":93,"time":1785730449034,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} +{"type":"assistant/chunk","seq":94,"time":1786357523292,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}} +{"type":"assistant/chunk","seq":95,"time":1786358035361,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":96,"time":1786358035361,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc56e00-c648-4669-92b2-7299e41cb743"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95],"surfaceOp":"append"} +{"type":"step/end","seq":97,"time":1786358035361,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":98,"time":1786358035361,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl index d6e54e2096..d81f1964b2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl @@ -1,20 +1,21 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} {"type":"subagent/descriptor","seq":0,"time":1785531795641,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","seq":1,"time":1785531795641,"data":{}} -{"type":"agent/inbox/spliced","seq":2,"time":1785730454803,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2a46160e-89d3-433b-bf04-66fb0313abfa"}]}} -{"type":"turn/start","seq":3,"time":1785821412774,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":4,"time":1785821412774,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":5,"time":1785730454835,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":6,"time":1785730454835,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2a46160e-89d3-433b-bf04-66fb0313abfa"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1785730454835,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"524be394-8639-4c12-a41d-799b9e0120a1"},"surfaceOp":"append"} -{"type":"session/title","seq":8,"time":1785730454835,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":9,"time":1785730454835,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":10,"time":1785730454835,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":11,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":12,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} -{"type":"assistant/chunk","seq":13,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":14,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":15,"time":1785730454843,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":16,"time":1785730454843,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f6a952dd-2d09-4b5c-b8ae-5456cfdfeab0"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} -{"type":"step/end","seq":17,"time":1785730454843,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":18,"time":1785730454844,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":2,"time":1786357532080,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357532080,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2a46160e-89d3-433b-bf04-66fb0313abfa"}]}} +{"type":"turn/start","seq":4,"time":1786357532081,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":1786357532081,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":6,"time":1786357532106,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":7,"time":1785730454835,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2a46160e-89d3-433b-bf04-66fb0313abfa"},"surfaceOp":"append"} +{"type":"user/message","seq":8,"time":1786357532106,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"5fda3f8d-fbac-4878-a9e3-9953a4e1da09"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":1786357532106,"data":{"title":"Reply with exactly the word","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":1785730454835,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":1785730454835,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":12,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":13,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":14,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":15,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":16,"time":1785730454843,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":17,"time":1785730454843,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f6a952dd-2d09-4b5c-b8ae-5456cfdfeab0"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1785730454843,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":19,"time":1785730454844,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl index f1deec5af9..06bd463ba8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821412725,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785730454783,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785730454783,"data":{"content":[{"type":"text","text":"Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. Then reply with the single word STARTED. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"c2febfff-792d-4457-a944-933ff0de0570"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730454783,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"cc8cb20d-5802-46a9-87b8-d3ee784f8e52"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730454783,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"c9d1f853-56bc-4082-ae08-00d4bcbb04a6"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730454783,"data":{"title":"Call the subagent tool once","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785730454784,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730454784,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl index e275607cbf..e510929344 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -1,22 +1,23 @@ {"type":"session","version":0,"id":"e4aafa18-b9e3-48d0-8aae-6c9b25dcae80","createdAt":1783352145223,"cwd":"{{cwd}}","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498797416,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"}]}} -{"type":"turn/start","seq":1,"time":1785821407754,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821407754,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821407767,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} -{"type":"step/start","seq":4,"time":1785730450187,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730450187,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730450187,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4a5a7c59-b6f8-47b0-8c09-d9a05607deac"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730450187,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730450187,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730450188,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783352146014,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1783352146042,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,28,0,0,0,0,0,29,0,0,0,0,29,0,0],"texts":["The"," user"," asked"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":30,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":31,"time0":1783352146129,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} -{"type":"assistant/chunk","seq":34,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":35,"time":1785498797444,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} -{"type":"assistant/chunk","seq":36,"time":1785730450194,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":37,"time":1785730450194,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":38,"time":1785730450194,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cfff210d-8dd3-4acc-bbc3-fa860baf88cf"},"usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37],"surfaceOp":"append"} -{"type":"step/end","seq":39,"time":1785730450194,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":40,"time":1785730450195,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357524735,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357524735,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"}]}} +{"type":"turn/start","seq":2,"time":1786357524735,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357524735,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357524752,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} +{"type":"step/start","seq":5,"time":1786357524755,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730450187,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357524755,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"56ad93fa-0cc0-4ccb-a1b4-258f4801c681"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357524755,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730450187,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730450188,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783352146014,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":12,"time0":1783352146042,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,28,0,0,0,0,0,29,0,0,0,0,29,0,0],"texts":["The"," user"," asked"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":31,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":32,"time0":1783352146129,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} +{"type":"assistant/chunk","seq":35,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":36,"time":1785498797444,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":37,"time":1785730450194,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":38,"time":1785730450194,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":39,"time":1785730450194,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cfff210d-8dd3-4acc-bbc3-fa860baf88cf"},"usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38],"surfaceOp":"append"} +{"type":"step/end","seq":40,"time":1785730450194,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":41,"time":1785730450195,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl index fb6e5e0971..a5f4ab88a4 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821407687,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783352142835,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498797379,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3d1ea7cb-c273-4c38-a765-5ff256eaaf51"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730450135,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"473ecf9e-52c4-4db2-be56-1c8f7fa7d932"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730450135,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e0a9678e-ff95-49f4-b4f7-4ace69a670a3"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730450135,"data":{"title":"Remember this fact for later:","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498797380,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730450136,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -20,21 +20,23 @@ {"type":"step/end","seq":34,"time":1785730450146,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":35,"time":1785730450146,"data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"session/end-seed","seq":36,"time":1785730450227,"data":{}} -{"type":"agent/inbox/spliced","seq":37,"time":1785498797482,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"}]}} -{"type":"turn/start","seq":38,"time":1785821407808,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":39,"time":1785821407808,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":40,"time":1785821407826,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} -{"type":"step/start","seq":41,"time":1785730450246,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":42,"time":1785730450246,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"},"surfaceOp":"append"} -{"type":"request/header","seq":43,"time":1785730450247,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} -{"type":"assistant/chunk","seq":44,"time":1783352148076,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":45,"time0":1783352148076,"data":{"turn":2,"step":1,"index":0,"dt":[1,0,0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0,1,0,0,31,1,0],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}} -{"type":"assistant/chunk","seq":76,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":77,"time0":1785142306309,"data":{"turn":2,"step":1,"index":1,"dt":[239267243,117223959],"texts":["SA","FF","RON"]}} -{"type":"assistant/chunk","seq":80,"time":1785498797511,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}}} -{"type":"assistant/chunk","seq":81,"time":1785498797511,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} -{"type":"assistant/chunk","seq":82,"time":1785730450254,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":83,"time":1785730450254,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":84,"time":1785730450254,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e1f347c1-ce65-4ca9-8a9e-05e4366ef365"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83],"surfaceOp":"append"} -{"type":"step/end","seq":85,"time":1785730450254,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":86,"time":1785730450254,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":37,"time":1786357524782,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":38,"time":1786357524783,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"}]}} +{"type":"turn/start","seq":39,"time":1786357524783,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":40,"time":1786357524783,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":41,"time":1786357524800,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} +{"type":"step/start","seq":42,"time":1786357524803,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":43,"time":1786357524803,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"},"surfaceOp":"append"} +{"type":"user/message","seq":44,"time":1786358036899,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"6ea5a774-b0da-47ff-84b7-226a4a207bbf"},"surfaceOp":"append"} +{"type":"request/header","seq":45,"time":1786358036900,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/chunk","seq":46,"time":1783352148077,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":47,"time0":1783352148077,"data":{"turn":2,"step":1,"index":0,"dt":[0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0,1,0,0,31,1,0,0,1790157964],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}} +{"type":"assistant/chunk","seq":78,"time":1785381573552,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":79,"time0":1785498797511,"data":{"turn":2,"step":1,"index":1,"dt":[0,0],"texts":["SA","FF","RON"]}} +{"type":"assistant/chunk","seq":82,"time":1785730450254,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}}} +{"type":"assistant/chunk","seq":83,"time":1785730450254,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} +{"type":"assistant/chunk","seq":84,"time":1786357524808,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":85,"time":1786358036906,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":86,"time":1786358036906,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e1f347c1-ce65-4ca9-8a9e-05e4366ef365"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"surfaceOp":"append"} +{"type":"step/end","seq":87,"time":1786358036906,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":88,"time":1786358036906,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl index fa390cf176..4963b5275b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821407687,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783352142835,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498797379,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3d1ea7cb-c273-4c38-a765-5ff256eaaf51"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730450135,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"473ecf9e-52c4-4db2-be56-1c8f7fa7d932"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730450135,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e0a9678e-ff95-49f4-b4f7-4ace69a670a3"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730450135,"data":{"title":"Remember this fact for later:","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498797380,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730450136,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl index 7948013736..da79d6b23c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -1,22 +1,23 @@ {"type":"session","version":0,"id":"553f8e92-aac1-4df3-8657-eacbb58f9581","createdAt":1783352127669,"cwd":"{{cwd}}","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498794788,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"}]}} -{"type":"turn/start","seq":1,"time":1785821405232,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821405232,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821405245,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} -{"type":"step/start","seq":4,"time":1785730447828,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730447828,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730447828,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"bab5cdff-7925-478d-b55a-daa2ef524d7c"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730447828,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730447828,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730447828,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1783352128280,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,19,0,0,0,0,1,31,0,0,0,0,32,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":30,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":31,"time0":1783352128365,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} -{"type":"assistant/chunk","seq":34,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":35,"time":1785498794825,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} -{"type":"assistant/chunk","seq":36,"time":1785730447834,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":37,"time":1785730447834,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":38,"time":1785730447834,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f1e6087-da72-4a56-9bc0-ae1ac6618a8a"},"usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37],"surfaceOp":"append"} -{"type":"step/end","seq":39,"time":1785730447834,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":40,"time":1785730447834,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357521737,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357521737,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"}]}} +{"type":"turn/start","seq":2,"time":1786357521737,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357521737,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357521754,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} +{"type":"step/start","seq":5,"time":1786357521756,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730447828,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357521756,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"f654b5a4-b4c0-4443-8eab-d84624d804f1"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357521756,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730447828,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730447828,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":12,"time0":1783352128280,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,19,0,0,0,0,1,31,0,0,0,0,32,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":31,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":32,"time0":1783352128365,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} +{"type":"assistant/chunk","seq":35,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":36,"time":1785498794825,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":37,"time":1785730447834,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":38,"time":1785730447834,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":39,"time":1785730447834,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f1e6087-da72-4a56-9bc0-ae1ac6618a8a"},"usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38],"surfaceOp":"append"} +{"type":"step/end","seq":40,"time":1785730447834,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":41,"time":1785730447834,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl index 64e58b3741..f7326e37c8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -1,23 +1,24 @@ {"type":"session","version":0,"id":"5f49e80c-16fc-42c7-a617-0b6bd0680aa3","createdAt":1783352129662,"cwd":"{{cwd}}","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498794853,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"}]}} -{"type":"turn/start","seq":1,"time":1785821405286,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821405286,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821405299,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} -{"type":"step/start","seq":4,"time":1785730447881,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730447881,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730447881,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"036067ef-a106-4955-841c-a0d2effe51ef"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730447881,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730447881,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730447881,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1783352130413,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,35,0,0,0,0,0,36,0,0,0,0,0,43],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","B","ETA","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":29,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":30,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}}} -{"type":"assistant/chunk","seq":31,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}}} -{"type":"assistant/chunk","seq":32,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":33,"time":1785498794882,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}}} -{"type":"assistant/chunk","seq":34,"time":1785730447887,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":35,"time":1785730447887,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":36,"time":1785730447887,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."},{"type":"text","text":"BETA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"adc4527d-efd1-4c89-b42b-826c33f2bb12"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35],"surfaceOp":"append"} -{"type":"step/end","seq":37,"time":1785730447887,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":38,"time":1785730447887,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357521782,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357521783,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"}]}} +{"type":"turn/start","seq":2,"time":1786357521783,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357521783,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357521799,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} +{"type":"step/start","seq":5,"time":1786357521801,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730447881,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357521802,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"05843da4-4a5f-46fc-a00f-5257b2bd271d"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357521802,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730447881,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730447881,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":12,"time0":1783352130413,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,35,0,0,0,0,0,36,0,0,0,0,0,43],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","B","ETA","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":30,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}}} +{"type":"assistant/chunk","seq":32,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}}} +{"type":"assistant/chunk","seq":33,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":34,"time":1785498794882,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}}} +{"type":"assistant/chunk","seq":35,"time":1785730447887,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":36,"time":1785730447887,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730447887,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."},{"type":"text","text":"BETA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"adc4527d-efd1-4c89-b42b-826c33f2bb12"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730447887,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730447887,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl index 3a48c2d760..a1337e166b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821405184,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783352126252,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498794765,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"07bf16df-0499-420d-9510-3204061f0122"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730447790,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"9b4b262d-cbd7-4cd8-b24b-70b2b401b0fe"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730447790,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"50a1100d-448e-41f2-8f99-39be199db492"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730447790,"data":{"title":"Use the subagent tool TWICE,","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498794766,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730447791,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.1.jsonl new file mode 100644 index 0000000000..a0433f1290 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.1.jsonl @@ -0,0 +1,2 @@ +{"type":"session","version":0,"id":"eb69342c-62b6-4320-a78b-961745f89333","createdAt":1786358409171,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} +{"type":"approval/policy","seq":0,"time":1786358409171,"data":{"policy":"never","source":"delegation"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl index b9992f1519..e32d1bdee0 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl @@ -1,30 +1,31 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} {"type":"subagent/descriptor","seq":0,"time":1785594881508,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Report a finding","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","seq":1,"time":1785594881508,"data":{}} -{"type":"agent/inbox/spliced","seq":2,"time":1785730453612,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"}]}} -{"type":"turn/start","seq":3,"time":1785821411475,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":4,"time":1785821411475,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":5,"time":1785730453639,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":6,"time":1785730453639,"data":{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1785730453639,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"67c76a21-6142-45a6-9a49-0485f51edc8d"},"surfaceOp":"append"} -{"type":"session/title","seq":8,"time":1785730453639,"data":{"title":"Call the report tool once","messageSeqs":[6],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":9,"time":1785730453639,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":10,"time":1785730453639,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":11,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":12,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_report_1","name":"report","argumentsDelta":"{\"output\": \"CHILD_REPORT_OK\"}"}}} -{"type":"assistant/chunk","seq":13,"time":1789000001010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}}}} -{"type":"assistant/chunk","seq":14,"time":1789000001011,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":15,"time":1785730453647,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":16,"time":1785730453647,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c9e50afb-b732-41ab-b0fc-8e98948ad9ec"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} -{"type":"tool/call","seq":17,"time":1785730453647,"data":{"turn":1,"step":1,"callId":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}} -{"type":"tool/result","seq":18,"time":1785730453654,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_report_1"},"content":[{"type":"tool-result","toolCallId":"call_report_1","content":[{"type":"text","text":"report accepted by the agent that started you as message 824dc60a-f9d7-48ea-a0d4-6d56df83bd4f"}],"isError":false}],"role":"user","id":"e6764773-c667-40b5-a13f-8bdc5a9c7762"}},"sourceEventSeqs":[17],"surfaceOp":"append"} -{"type":"step/end","seq":19,"time":1785730453654,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":20,"time":1785730453664,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":21,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":22,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Reported."}}} -{"type":"assistant/chunk","seq":23,"time":1789000001020,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Reported."}}}} -{"type":"assistant/chunk","seq":24,"time":1789000001021,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":25,"time":1785730453668,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":26,"time":1785730453668,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Reported."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"96784835-2d0f-4d00-aef5-ee3a14820dd1"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} -{"type":"step/end","seq":27,"time":1785730453668,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":28,"time":1785730453668,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":2,"time":1786357530605,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357530605,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"}]}} +{"type":"turn/start","seq":4,"time":1786357530605,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":1786357530605,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":6,"time":1786357530633,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":7,"time":1785730453639,"data":{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"},"surfaceOp":"append"} +{"type":"user/message","seq":8,"time":1786357530633,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"1677b901-cce7-461a-8b2a-7f119dd9d845"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":1786357530633,"data":{"title":"Call the report tool once","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":1785730453639,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":1785730453639,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":12,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":13,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_report_1","name":"report","argumentsDelta":"{\"output\": \"CHILD_REPORT_OK\"}"}}} +{"type":"assistant/chunk","seq":14,"time":1789000001010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}}}} +{"type":"assistant/chunk","seq":15,"time":1789000001011,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":16,"time":1785730453647,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":17,"time":1785730453647,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c9e50afb-b732-41ab-b0fc-8e98948ad9ec"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"tool/call","seq":18,"time":1785730453647,"data":{"turn":1,"step":1,"callId":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}} +{"type":"tool/result","seq":19,"time":1785730453654,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_report_1"},"content":[{"type":"tool-result","toolCallId":"call_report_1","content":[{"type":"text","text":"report accepted by the agent that started you as message 1f4b61e2-6c6d-4db8-836b-ac5760c5e484"}],"isError":false}],"role":"user","id":"cee5f084-bfab-423d-b8bc-1b1b7d88d4fa"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":1785730453654,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":21,"time":1785730453664,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":22,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":23,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Reported."}}} +{"type":"assistant/chunk","seq":24,"time":1789000001020,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Reported."}}}} +{"type":"assistant/chunk","seq":25,"time":1789000001021,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":26,"time":1785730453668,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":27,"time":1785730453668,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Reported."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"96784835-2d0f-4d00-aef5-ee3a14820dd1"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} +{"type":"step/end","seq":28,"time":1785730453668,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":29,"time":1785730453668,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl index db5cd4a77f..eb36af2399 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821411429,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785730453591,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785730453591,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Report a finding', and prompt 'Call the report tool once with output exactly CHILD_REPORT_OK, then stop.'. 2. Reply with the single word STARTED. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"5cf78378-e004-4fd5-af4f-cef3b7e190ad"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730453592,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"77141070-eb99-4ec0-908d-646c387982f6"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730453592,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"d1a851a3-604f-4a42-8e5f-4e480857a3b4"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730453592,"data":{"title":"Follow these steps exactly, then","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785730453592,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730453593,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 7fa229c2e3..5eb0455932 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -1,22 +1,23 @@ {"type":"session","version":0,"id":"ea339828-7885-42e1-9083-4355e6f1708d","createdAt":1783352120855,"cwd":"{{cwd}}","parentSession":"5138ed0d-e86e-4a7d-b75b-803307e92b17","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498793648,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"}]}} -{"type":"turn/start","seq":1,"time":1785821404007,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821404007,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821404020,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply with CHILD_OK"}} -{"type":"step/start","seq":4,"time":1785730446720,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730446720,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730446720,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"1b537017-6493-4f52-8504-01a7384e8cc6"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730446720,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730446720,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730446721,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783352121663,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1783352121664,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,27,0,0,29,0,0,27,0,0,0,0,1],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," CH","ILD","_OK"," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":28,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":29,"time0":1783352121777,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["CH","ILD","_OK"]}} -{"type":"assistant/chunk","seq":32,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."}}}} -{"type":"assistant/chunk","seq":33,"time":1785498793670,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":34,"time":1785730446727,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":35,"time":1785730446727,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":36,"time":1785730446727,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"16118fc6-2262-476e-9a4a-4b533cff09bc"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35],"surfaceOp":"append"} -{"type":"step/end","seq":37,"time":1785730446727,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":38,"time":1785730446727,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357520283,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357520283,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"}]}} +{"type":"turn/start","seq":2,"time":1786357520283,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357520283,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357520300,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply with CHILD_OK"}} +{"type":"step/start","seq":5,"time":1786357520303,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730446720,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357520303,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"24630f5a-f790-469f-96a6-cf234ded3759"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357520303,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730446720,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730446721,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783352121663,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":12,"time0":1783352121664,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,27,0,0,29,0,0,27,0,0,0,0,1],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," CH","ILD","_OK"," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":29,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":30,"time0":1783352121777,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["CH","ILD","_OK"]}} +{"type":"assistant/chunk","seq":33,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."}}}} +{"type":"assistant/chunk","seq":34,"time":1785498793670,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":35,"time":1785730446727,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":36,"time":1785730446727,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730446727,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"16118fc6-2262-476e-9a4a-4b533cff09bc"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730446727,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730446727,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl index edf8950dac..0ac1be7454 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821403947,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783352119275,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498793625,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"a9485ebd-2b4a-434a-bc35-afd757ce141b"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730446685,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e40b1354-1856-48c3-a638-1be67af32920"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730446685,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"bfd99a70-ad54-4073-9c0d-8a63711fe34a"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730446685,"data":{"title":"Use the subagent tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498793626,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730446686,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl index 7b77a09f5e..080198e7bc 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl @@ -1,22 +1,23 @@ {"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"{{cwd}}","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498800317,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"}]}} -{"type":"turn/start","seq":1,"time":1785821416523,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821416523,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821416542,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} -{"type":"step/start","seq":4,"time":1785730457309,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730457309,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730457309,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e076edc0-a2bf-4fc6-aa58-d44bf1e8fd00"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730457309,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730457310,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730457310,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1783600638189,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,24,0,0,0,0,29,0,0,0,0,0,34,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WF","_CH","ILD","_OK","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":29,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":30,"time0":1783600638276,"data":{"turn":1,"step":1,"index":1,"dt":[4,0,0],"texts":["WF","_CH","ILD","_OK"]}} -{"type":"assistant/chunk","seq":34,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} -{"type":"assistant/chunk","seq":35,"time":1785498800343,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":36,"time":1785730457316,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":37,"time":1785730457316,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":38,"time":1785730457316,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0ddaf3d1-53dc-45df-bc19-54ad72d6d7fb"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37],"surfaceOp":"append"} -{"type":"step/end","seq":39,"time":1785730457316,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":40,"time":1785730457316,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357536718,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357536719,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"}]}} +{"type":"turn/start","seq":2,"time":1786357536719,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357536719,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357536736,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"step/start","seq":5,"time":1786357536738,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730457309,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357536738,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"12bbd4dd-4040-4cc7-8acf-e526144f1ee5"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357536738,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730457310,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730457310,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":12,"time0":1783600638189,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,24,0,0,0,0,29,0,0,0,0,0,34,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WF","_CH","ILD","_OK","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":30,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":31,"time0":1783600638276,"data":{"turn":1,"step":1,"index":1,"dt":[4,0,0],"texts":["WF","_CH","ILD","_OK"]}} +{"type":"assistant/chunk","seq":35,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} +{"type":"assistant/chunk","seq":36,"time":1785498800343,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":37,"time":1785730457316,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":38,"time":1785730457316,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":39,"time":1785730457316,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0ddaf3d1-53dc-45df-bc19-54ad72d6d7fb"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38],"surfaceOp":"append"} +{"type":"step/end","seq":40,"time":1785730457316,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":41,"time":1785730457316,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index 6ee104dd0c..eff3a129a4 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821416248,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783600631839,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498800152,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"},"role":"user","id":"5188a9c7-d3ca-4679-b8df-1443e0a0a4df"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730457160,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"1c92c213-1d4f-45ad-be50-161f26a23e65"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730457160,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"8f2bd7f3-ba01-4448-b00a-0d6e9c868fc3"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730457160,"data":{"title":"Use the workflow tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498800153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730457161,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 44cbf0e360..8a1c23140b 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,18 +1,19 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fc62f9e7-b8f6-441f-9ee8-17f1f9e4feca"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"1c70f3f7-2e85-4808-8376-03d4d3bee6e6"}]}} {"type":"turn/start","seq":1,"time":1785821454445,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454445,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","seq":3,"time":1785821454466,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","seq":4,"time":1785730501506,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fc62f9e7-b8f6-441f-9ee8-17f1f9e4feca"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":1785730501506,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record<string, JsonValue>;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record<string, JsonValue>;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record<string, JsonValue>;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record<string, JsonValue>;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record<string, JsonValue>)[];\n } & Record<string, JsonValue>;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record<string, JsonValue>;\n } & Record<string, JsonValue>;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1785730501507,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":10,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":12,"time":1785498583897,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":13,"time":1785730501507,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cac680cf-1d70-4fb2-91a3-da1e3a317d2e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785730501507,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":16,"time":1785730501507,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"1c70f3f7-2e85-4808-8376-03d4d3bee6e6"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786358103673,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"c9a305d4-add2-453e-8789-4e5c127725f7"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786358103673,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record<string, JsonValue>;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record<string, JsonValue>;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record<string, JsonValue>;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record<string, JsonValue>;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record<string, JsonValue>)[];\n } & Record<string, JsonValue>;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record<string, JsonValue>;\n } & Record<string, JsonValue>;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730501507,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":12,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":13,"time":1785498583897,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":14,"time":1785730501507,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":15,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c5d4e091-9632-4535-af35-097bc74abdd3"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1785730501507,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":17,"time":1785730501507,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 6988595618..8882bda5af 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,18 +1,19 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"093bfc20-c6fc-4573-b172-2c6ca40c188b"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"12fb8b24-9214-4e43-b3d3-47f2af3531f1"}]}} {"type":"turn/start","seq":1,"time":1785821454599,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454599,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","seq":3,"time":1785821454618,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","seq":4,"time":1785730501645,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"093bfc20-c6fc-4573-b172-2c6ca40c188b"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":1785730501645,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record<string, JsonValue>;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record<string, JsonValue>;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record<string, JsonValue>;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record<string, JsonValue>;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record<string, JsonValue>)[];\n } & Record<string, JsonValue>;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record<string, JsonValue>;\n } & Record<string, JsonValue>;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1785730501646,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":10,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":12,"time":1785498584067,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":13,"time":1785730501646,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2b31dae5-8939-44e1-bbcd-9f64aa637d76"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785730501646,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":16,"time":1785730501646,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"12fb8b24-9214-4e43-b3d3-47f2af3531f1"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786358103827,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"14cf8f47-3a7a-4857-a548-02fe407683fb"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786358103827,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record<string, JsonValue>;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record<string, JsonValue>;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record<string, JsonValue>;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record<string, JsonValue>;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record<string, JsonValue>)[];\n } & Record<string, JsonValue>;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record<string, JsonValue>;\n } & Record<string, JsonValue>;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730501646,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":12,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":13,"time":1785498584067,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":14,"time":1785730501646,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":15,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bb9c39a9-3239-4ee1-939a-bab0046e3028"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1785730501646,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":17,"time":1785730501646,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 646110b6d9..8f0a211969 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,20 +1,20 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"2b5f9025-324a-4c76-b883-4af3b5c3060a"}]}} {"type":"turn/start","seq":1,"time":1785821454304,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454304,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"2b5f9025-324a-4c76-b883-4af3b5c3060a"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1785498583779,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record<string, JsonValue>;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record<string, JsonValue>;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record<string, JsonValue>;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record<string, JsonValue>;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record<string, JsonValue>)[];\n } & Record<string, JsonValue>;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record<string, JsonValue>;\n } & Record<string, JsonValue>;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record<string, JsonValue>;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record<string, JsonValue>;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record<string, JsonValue>;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record<string, JsonValue>;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record<string, JsonValue>)[];\n } & Record<string, JsonValue>;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record<string, JsonValue>;\n } & Record<string, JsonValue>;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785730501403,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":8,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":10,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1785498583784,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1785730501404,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e65c0ebe-8e3d-44c0-833f-68efcbc0acb5"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b596133f-aafe-4485-9871-ade1dda23373"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1785730501404,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"abb8ecee-cb03-4a66-9477-38a52458ab05"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"99819f4a-5e53-4a6f-92f6-5be96b765bce"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1785730501413,"data":{"turn":1,"step":1}} {"type":"step/start","seq":17,"time":1785730501423,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -22,11 +22,11 @@ {"type":"assistant/chunk","seq":20,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} {"type":"assistant/chunk","seq":21,"time":1785498583804,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":22,"time":1785730501424,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc95327-3ce1-49ea-8a92-b17e450cc455"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6697e967-e6bd-46b2-8574-18aeb914e7c6"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","seq":24,"time":1785730501424,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} {"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} {"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} -{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"d75c7d03-cbbc-4260-ba40-8c210a3b5bbe"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"831070a8-3dc0-4275-9f11-0476b47b8ef2"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1785730501475,"data":{"turn":1,"step":2}} {"type":"step/start","seq":29,"time":1785730501483,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":30,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -34,9 +34,9 @@ {"type":"assistant/chunk","seq":32,"time":1785037378923,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} {"type":"assistant/chunk","seq":33,"time":1785498583869,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":34,"time":1785730501484,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ba4958e9-231c-437f-a2fc-7a13f392d3ba"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} +{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cef24cf0-5f9e-4be2-93d8-93a0d89c6e82"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} {"type":"tool/call","seq":36,"time":1785730501484,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"b9ebb37d-e565-4882-95b0-5343da1d68d8"}},"sourceEventSeqs":[36],"surfaceOp":"append"} +{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"d7ea395e-840a-46c4-a143-b6e15cf74114"}},"sourceEventSeqs":[36],"surfaceOp":"append"} {"type":"step/end","seq":38,"time":1785730501508,"data":{"turn":1,"step":3}} {"type":"step/start","seq":39,"time":1785730501521,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -44,9 +44,9 @@ {"type":"assistant/chunk","seq":42,"time":1785037378946,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} {"type":"assistant/chunk","seq":43,"time":1785498583919,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":44,"time":1785730501522,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4757f4b9-9bde-488b-a54a-1bdea55dd15f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} +{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"242be4fa-3293-45de-ab55-a017999f2333"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} {"type":"tool/call","seq":46,"time":1785730501522,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} -{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"35baa460-54ff-4fa1-ba9d-66b6661f84e9"}},"sourceEventSeqs":[46],"surfaceOp":"append"} +{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"ee764f11-8827-4984-acef-ec3c5880f5a0"}},"sourceEventSeqs":[46],"surfaceOp":"append"} {"type":"step/end","seq":48,"time":1785730501648,"data":{"turn":1,"step":4}} {"type":"step/start","seq":49,"time":1785730501660,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -54,9 +54,9 @@ {"type":"assistant/chunk","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":53,"time":1785498584085,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":54,"time":1785730501661,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"739166e2-ed48-4df2-a9a5-207f34058030"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} +{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6c3dab82-c2ed-492a-ab0d-f235b340a6c1"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} {"type":"tool/call","seq":56,"time":1785730501661,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"98b05c06-cb77-41a9-8310-324bc72fc7a0"}},"sourceEventSeqs":[56],"surfaceOp":"append"} +{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"74554b42-640e-4119-a413-ee5ac01e546e"}},"sourceEventSeqs":[56],"surfaceOp":"append"} {"type":"step/end","seq":58,"time":1785730501668,"data":{"turn":1,"step":5}} {"type":"step/start","seq":59,"time":1785730501678,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -64,6 +64,6 @@ {"type":"assistant/chunk","seq":62,"time":1785037379541,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} {"type":"assistant/chunk","seq":63,"time":1785498584102,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":64,"time":1785730501679,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0a4ca8f2-92c1-4dbc-beb8-923b8791c298"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} +{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1a3bef32-0610-4891-9071-6bdc2e8a8fd2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} {"type":"step/end","seq":66,"time":1785730501679,"data":{"turn":1,"step":6}} {"type":"turn/end","seq":67,"time":1785730501679,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl index dbcd59cc5a..1711b58c84 100644 --- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Delegated write probe"}} {"type":"step/start","seq":5,"time":0,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":0,"data":{"title":"Use the write tool exactly","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl index 434027310a..fc0a24eb66 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl @@ -106,37 +106,38 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"echo probe"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly: child answer","messageSeqs":[5],"source":{"kind":"fallback"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"child"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"child"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" answer"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" "}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"42"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":34,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":35,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":36,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":7,"time":0,"data":{"title":"Reply with exactly: child answer","messageSeqs":[5],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":8,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":9,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":35,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":37,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} {"method":"subagent.finished","params":{"provider":"spawn","agentId":"{{sessionId}}","parentSessionId":"{{sessionId}}","childSessionId":"{{sessionId}}","status":"ok","stopReason":"completed","lastAssistantMessage":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}]}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":99,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_oHPNQ1nLoakoaAGXIxCM7404"},"content":[{"type":"tool-result","toolCallId":"call_00_oHPNQ1nLoakoaAGXIxCM7404","content":[{"type":"text","text":"child answer 42."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[98],"surfaceOp":"append"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 9c45784001..0e7855cd86 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -5,17 +5,18 @@ {"type":"subagent/descriptor","seq":3,"time":1785821461003,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"echo probe"}} {"type":"step/start","seq":4,"time":1785730507335,"data":{"turn":1,"step":1}} {"type":"user/message","seq":5,"time":1785730507335,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"7ae1698c-db1d-4fca-8404-3a9dece9c1d0"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":1785730507335,"data":{"title":"Reply with exactly: child answer","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498591175,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1785730507336,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":9,"time":1785097410985,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":10,"time0":1785097411011,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,24,1,0,0,0,25,0,1,0,51,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","child"," answer"," ","42",".\""]}} -{"type":"assistant/chunk","seq":24,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":25,"time0":1785097411114,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,24,0],"texts":["child"," answer"," ","42","."]}} -{"type":"assistant/chunk","seq":30,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}} -{"type":"assistant/chunk","seq":31,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} -{"type":"assistant/chunk","seq":32,"time":1785498591184,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}} -{"type":"assistant/chunk","seq":33,"time":1785730507343,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":34,"time":1785730507344,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3d9970cd-d000-4fd5-8712-a88c301ddb19"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"} -{"type":"step/end","seq":35,"time":1785730507344,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":36,"time":1785730507344,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":6,"time":1786358111405,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"17bd0771-d228-4805-a797-7be9c0b59d20"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786358111405,"data":{"title":"Reply with exactly: child answer","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785498591175,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730507336,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1785097410985,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":11,"time0":1785097411011,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,24,1,0,0,0,25,0,1,0,51,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","child"," answer"," ","42",".\""]}} +{"type":"assistant/chunk","seq":25,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":26,"time0":1785097411114,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,24,0],"texts":["child"," answer"," ","42","."]}} +{"type":"assistant/chunk","seq":31,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}} +{"type":"assistant/chunk","seq":32,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} +{"type":"assistant/chunk","seq":33,"time":1785498591184,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":34,"time":1785730507343,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":35,"time":1785730507344,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3d9970cd-d000-4fd5-8712-a88c301ddb19"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"} +{"type":"step/end","seq":36,"time":1785730507344,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":37,"time":1785730507344,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 7598b6dc3e..78106c0d6d 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: 4189979806ccd4e7dcfee231ab3e9e2331550b0d -README.zh.md: c6a9005cbfdb9d40a54383f921671fa22a31dc32 +README.md: 209f1e9526ff4a01af6f4c96955068de4b2b06c0 +README.zh.md: 8623be4bc1ab39aa7718de204dd0507843b0ab14 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 4189979806..209f1e9526 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -20,7 +20,7 @@ The child gets the parent's working-directory/session lineage and inherits the p This result boundary is valid because the provider owns an isolated child lifecycle from publication through quiescence. Steering submitted during that lifecycle belongs to the child run; the provider does not pretend the initial follow-up alone owns its output. -The driver applies the seam's [delegated policy inheritance](../subagent/README.md#delegated-policy-inheritance) through the shared child-agent helpers: it captures the parent's explicit sandbox/approval overrides before child creation and appends the source-tagged events during unpublished setup, after any fork history and before session publication. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). +The driver applies the seam's [delegated policy](../subagent/README.md#delegated-policy) through the shared child-agent helpers: it captures the parent's explicit sandbox override and the `'never'` approval pin before child creation and appends the source-tagged events during unpublished setup, after any fork history and before session publication. See the [delegation-policy decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). ## Cancellation and ownership diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index c6a9005cbf..8623be4bc1 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -20,7 +20,7 @@ 该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。 -驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略继承](../subagent/README.md#delegated-policy-inheritance):它会在创建子 agent 前捕获父级的显式沙箱/审批覆盖项,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 +驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略](../subagent/README.md#delegated-policy):它会在创建子 agent 前捕获父级的显式沙箱覆盖项与 `'never'` 审批钉定,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[委派策略决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 ## 取消与所有权 diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts index 804249ba77..17620f7774 100644 --- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -1,4 +1,7 @@ -/** Policy inheritance through child session events appended before publication. */ +/** + * Delegation policy through child session events appended before publication: + * the parent's sandbox override plus the pinned `approval/policy: never`. + */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises' @@ -13,7 +16,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' -import ApprovalService, { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import ApprovalService from '@deepseek-ai/dsh-user-approval' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' @@ -76,12 +79,14 @@ function toolResultTexts(agent: Agent): string[] { } describe('in-process policy inheritance', () => { - it('records parent overrides before publishing a spawn child', async () => { + it('records the parent sandbox override and the approval pin before publishing a spawn child', async () => { const script: Script = [] const { ctx, parent } = await setupWalled(script) const blocked = join(workspace, 'spawn-blocked.txt') setSandboxMode(parent.session, 'read-only') - setApprovalPolicy(parent.session, 'never') + // The parent keeps the interactive deployment default: the child pin must + // not depend on any parent approval override. + expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() const parentLogLength = parent.session.events.length script.push( toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), @@ -120,7 +125,11 @@ describe('in-process policy inheritance', () => { .join('\n') expect(contextText).toContain('Current DSH file policy: read-only') expect(contextText).toContain('Approval prompts are disabled') + // The delegation-scope statement is a runtime-context fact, so the + // deployment system prompt stays uniform across parents and children. + expect(contextText).toContain('You are a delegated subagent') expect(request.data.header.system).not.toContain('Approval prompts are disabled') + expect(request.data.header.system).not.toContain('You are a delegated subagent') expect(parent.session.events).toHaveLength(parentLogLength) } finally { await run.dispose() @@ -179,7 +188,7 @@ describe('in-process policy inheritance', () => { } }) - it('does not freeze deployment defaults into an unswitched child', async () => { + it('leaves an unswitched sandbox on the deployment default while still pinning approval', async () => { const script: Script = [] const { parent } = await setupWalled(script) const allowed = join(workspace, 'default-allowed.txt') @@ -193,12 +202,58 @@ describe('in-process policy inheritance', () => { await run.result const child = run.localAgent as Agent expect(await readFile(allowed, 'utf8')).toBe('fine') - expect(child.session.events.some( - event => event.type === 'sandbox/mode' || event.type === 'approval/policy', - )).toBe(false) + expect(child.session.events.some(event => event.type === 'sandbox/mode')).toBe(false) + expect(child.session.events.filter(event => event.type === 'approval/policy')).toMatchObject([ + { seq: 0, data: { policy: 'never', source: 'delegation' } }, + ]) expect(child.session.firstLiveSeq).toBe(0) } finally { await run.dispose() } }) + + it('rejects a child escalation deterministically even when an answerer would allow it', async () => { + const script: Script = [] + const { ctx, parent } = await setupWalled(script) + // A root answerer that would GRANT: the pinned 'never' must resolve + // before any answerer is consulted, so this never runs for the child. + let consulted = false + ctx.on('approval/request', () => { + consulted = true + return Promise.resolve('allowed-once' as const) + }) + const blocked = join(workspace, 'escalation-blocked.txt') + setSandboxMode(parent.session, 'read-only') + script.push( + toolCallResponse('write', 'write', { + file_path: blocked, + content: 'escaped', + sandbox_permissions: 'workspace-write', + justification: 'test escalation from a delegated child', + }), + textResponse('child done'), + ) + + const run = await startInProcessRun(spawnRequest(parent), {}) + try { + await run.result + const child = run.localAgent as Agent + + await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(consulted).toBe(false) + expect(toolResultTexts(child).join('\n')) + .toContain('the user rejected escalating this operation to "workspace-write"') + // The deterministic rejection still leaves the full audit pair on the child log. + const asked = child.session.events.find( + (event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked', + ) + const decided = child.session.events.find( + (event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided', + ) + expect(asked?.data.toolName).toBe('write') + expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' }) + } finally { + await run.dispose() + } + }) }) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 06fa641336..36e63283da 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -247,10 +247,11 @@ describe('in-process structured output', () => { const result = await run.result expect(result.stopReason).toBe('error') expect(result.structured).toBeUndefined() - // Exactly one model request and one user message: no nudge turn exists. + // Exactly one model request and one caller-supplied user message (the + // delegation runtime-context snapshot aside): no nudge turn exists. expect(adapter.requests.length).toBe(1) const child = ctx.agents.get(run.id)! - expect(child.session.events.filter(e => e.type === 'user/message').length).toBe(1) + expect(child.session.events.filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1) await run.dispose() }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 7c64fb7228..3c89396d41 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: 6cea175de3d07290b293ad55ccbe60d7d918d37c -README.zh.md: c5fecd554357146d317b5f75824f40a1cb976f2c +README.md: 30ecd187b08cd3d098ce791535914f80e4be9aed +README.zh.md: 5e32a74469a67e02a0eb927c368080768e27d508 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 6cea175de3..30ecd187b0 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -52,9 +52,9 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th `inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and the out-of-process one-shot providers do not), not whether it inherits tools, services, or authority. -## Delegated policy inheritance +## Delegated policy -Both in-process delegation paths seed the parent's explicit policy overrides into the child through the shared child-agent helpers: `captureDelegatedPolicyOverrides(parent)` snapshots `sandboxPolicy.overrideOf()` and `approval.overrideOf()` synchronously at the delegation boundary (both services are optional `ctx.get` consumers), and `appendDelegatedPolicyOverrides()` writes each captured value onto the child's own log as a `source: 'delegation'` `sandbox/mode` or `approval/policy` event during unpublished setup, after any fork seed — so fresh policy wins stale seed state, a later child switch wins the snapshot, and the child's effective policy stays reconstructable from its log alone. Deployment defaults are never copied: an unswitched parent stamps nothing and its child follows the deployment default dynamically. A continuable start captures before its first await and seeds only fresh materialization; a cold resume replays the persisted delegation events instead of re-capturing the parent, so a parent switch after creation never retroactively changes a durable child. See the [one-shot](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md) and [continuable](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md) policy-inheritance Agent Notes. +Both in-process delegation paths fix the child's permission scope at the delegation boundary through the shared child-agent helpers. `captureDelegatedPolicyOverrides(parent)` snapshots the parent session's explicit sandbox override (`sandboxPolicy.overrideOf()`) and pins the child's approval policy to `'never'` whenever the approval capability is composed — regardless of the parent's own policy — so a delegated child acts only within its inherited sandbox scope and every ask (for example a `sandbox_permissions` escalation) is rejected deterministically instead of waiting on a prompt no one is watching (both services are optional `ctx.get` consumers). `appendDelegatedPolicyOverrides()` writes each value onto the child's own log as a `source: 'delegation'` `sandbox/mode` or `approval/policy` event during unpublished setup, after any fork seed — so fresh policy wins stale seed state and the child's effective policy stays reconstructable from its log alone. The sandbox deployment default is never copied: an unswitched parent stamps no `sandbox/mode` and its child follows the deployment default dynamically. A continuable start captures before its first await and seeds only fresh materialization; a cold resume replays the persisted delegation events instead of re-capturing the parent, so a parent switch after creation never retroactively changes a durable child. Every in-process child also receives a scoped runtime-context statement (`subagent:delegation`) telling it the scope is fixed and that a task needing wider access ends with a reported limitation, not retries. See the [one-shot](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md) and [continuable](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md) delegation-policy Agent Notes. ## One-shot ownership and lifecycle @@ -96,11 +96,25 @@ Continuable Activations await a best-effort final session flush without treating ## Model Experience -Indirectly, through `dsh-tool-subagent`, `dsh-tool-subagent-control`, and `dsh-tool-subagent-report`. The first owns delegation schemas, the second owns parent continuation and discovery, and the third contributes `report` only to continuable child scopes. +### Child delegation-scope statement + +#### What the model sees + +Every in-process child's runtime-context snapshot carries the `subagent:delegation` statement below, after the sandbox-policy and approval-policy sentences; parent-side rendering stays with `dsh-tool-subagent` (delegation schemas), `dsh-tool-subagent-control` (continuation and discovery), and `dsh-tool-subagent-report` (the child-scoped `report`). + +##### The delegation-scope statement + +```markdown +You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it. +``` + +#### Token effect + +One fixed statement in each child's runtime-context snapshot; none in the parent's requests. #### KV Cache effect -No direct invalidation; the named consumers own any request-prefix changes. +Prefix-stable within a child: the statement never changes during the child's lifetime, so it is written once into the first runtime-context snapshot. Parent-side, no direct invalidation; the named tool consumers own any request-prefix changes. ## Known Limitations and Deferred Work diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index c5fecd5543..5e32a74469 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -52,9 +52,9 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。 -## 委派策略继承 +## 委派策略 -两条进程内委派路径都会通过共享的子 agent 辅助函数,把父级的显式策略覆盖项作为种子注入子 agent:`captureDelegatedPolicyOverrides(parent)` 在委派边界同步对 `sandboxPolicy.overrideOf()` 与 `approval.overrideOf()` 获取快照(这两个服务都是可选的 `ctx.get` 消费方),`appendDelegatedPolicyOverrides()` 则在未发布的设置阶段、在任何 fork 种子之后,把每个捕获值作为一条 `source: 'delegation'` 的 `sandbox/mode` 或 `approval/policy` 事件写入子 agent 自己的日志:因此新鲜策略压过陈旧的种子状态,子 agent 后续的切换压过该快照,而子 agent 的生效策略始终可以仅凭其日志重建。部署默认值绝不复制:未切换的父级不会记录任何值,其子 agent 会动态跟随部署默认值。可继续启动会在其第一次 await 之前捕获,并且只为新鲜的物化写入种子;冷恢复会重放已持久化的委派事件,而不是重新捕获父级,因此创建之后的父级切换绝不会追溯性地改变持久化子 agent。参见[一次性](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)与[可继续](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md)两篇策略继承 Agent Note。 +两条进程内委派路径都会通过共享的子 agent 辅助函数,在委派边界固定子 agent 的权限范围。`captureDelegatedPolicyOverrides(parent)` 对父会话的显式沙箱覆盖项(`sandboxPolicy.overrideOf()`)获取快照,并在审批能力已组合时把子 agent 的审批策略钉定为 `'never'`——无论父级自身的策略是什么——因此被委派的子 agent 只在其继承的沙箱范围内行动,每次请求(例如一次 `sandbox_permissions` 升级)都被确定性拒绝,而不是等待一个无人在看的提示(这两个服务都是可选的 `ctx.get` 消费方)。`appendDelegatedPolicyOverrides()` 则在未发布的设置阶段、在任何 fork 种子之后,把每个值作为一条 `source: 'delegation'` 的 `sandbox/mode` 或 `approval/policy` 事件写入子 agent 自己的日志:因此新鲜策略压过陈旧的种子状态,而子 agent 的生效策略始终可以仅凭其日志重建。沙箱的部署默认值绝不复制:未切换的父级不会记录 `sandbox/mode`,其子 agent 会动态跟随部署默认值。可继续启动会在其第一次 await 之前捕获,并且只为新鲜的物化写入种子;冷恢复会重放已持久化的委派事件,而不是重新捕获父级,因此创建之后的父级切换绝不会追溯性地改变持久化子 agent。每个进程内子 agent 还会收到一条作用域内的运行时上下文声明(`subagent:delegation`),告知其权限范围已固定,需要更宽访问的任务应以上报限制收尾,而不是重试。参见[一次性](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)与[可继续](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md)两篇委派策略 Agent Note。 ## 一次性所有权与生命周期 @@ -96,11 +96,25 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 模型体验 -通过 `dsh-tool-subagent`、`dsh-tool-subagent-control` 和 `dsh-tool-subagent-report` 间接产生影响。第一个工具负责委派 schema,第二个负责父级延续和发现,第三个只向可继续子级作用域贡献 `report`。 +### 子级委派范围声明 + +#### 模型看到的内容 + +每个进程内子 agent 的运行时上下文快照都携带下方的 `subagent:delegation` 声明,位于沙箱策略与审批策略语句之后;父级侧的渲染仍归 `dsh-tool-subagent`(委派 schema)、`dsh-tool-subagent-control`(延续与发现)和 `dsh-tool-subagent-report`(子级作用域的 `report`)所有。 + +##### 委派范围声明 + +```markdown +You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it. +``` + +#### Token 影响 + +每个子 agent 的运行时上下文快照中一条固定声明;父级请求中没有任何新增。 #### KV Cache 影响 -不会直接使缓存失效;具名消费方共同负责请求前缀的任何变化。 +子级内部前缀稳定:该声明在子 agent 生命周期内绝不变化,因此只写入第一份运行时上下文快照一次。父级侧不会直接使缓存失效;具名工具消费方共同负责请求前缀的任何变化。 ## 已知限制与暂缓事项 diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index 878b831dd5..bc0cf949b9 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -1,9 +1,9 @@ /** * Shared in-process child composition: the delegation-depth budget, the * durable session metadata, the resolved child `AgentOptions`, the delegated - * policy snapshot, and the scoped setup a child agent needs. Both the one-shot + * policy seed, and the scoped setup a child agent needs. Both the one-shot * provider driver and the continuation manager compose children this way, so - * depth accounting, lineage stamping, and policy inheritance have one home. + * depth accounting, lineage stamping, and delegation policy have one home. * * @module @deepseek-ai/dsh-subagent/child-agent */ @@ -13,12 +13,10 @@ import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-a import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' -import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' // Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve // to the policy services when composed — delegation consumes both -// opportunistically (the documented `ctx.get` pattern), never as a hard dep. -// The user-approval side stays an explicit empty import so its augmentation -// does not ride the `ApprovalPolicy` import above. +// opportunistically (the documented `ctx.get` pattern), never as a hard dep — +// and merge the `sandbox/mode` / `approval/policy` session-event payloads. import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-user-approval' import { delegationDepthOf } from './depth.ts' @@ -115,51 +113,80 @@ export interface ChildComposition { } /** - * Apply one child's scoped composition inside its creation window: a shadowing - * persona section and a tool restriction, both owned by the child's scope and - * therefore invisible to its parent and siblings. + * Model-facing statement every in-process child receives: the permission + * scope is fixed at delegation and approval prompts are unavailable, so the + * child reports a scope limitation instead of retrying denied operations. + * A runtime-context contribution (not a system-prompt section) because it is + * a per-session fact: the deployment's system prompt stays uniform across + * parents and children, and the statement joins the same durable snapshot + * that carries the sandbox-policy and approval-policy sentences. + */ +export const SUBAGENT_DELEGATION_CONTEXT + = 'You are a delegated subagent: your permission scope was fixed when you were started and cannot be ' + + 'widened from inside this session — operations that require approval are rejected automatically. ' + + 'When the task needs access beyond that scope, do not retry the denied operation; state the ' + + 'limitation in your reply so the delegating agent can handle it.' + +/** + * Apply one child's scoped composition inside its creation window: the fixed + * delegation-scope statement, a shadowing persona section, and a tool + * restriction, all owned by the child's scope and therefore invisible to its + * parent and siblings. Both creation and cold resume pass through here, so a + * resumed child keeps the same statement. * @param childCtx - the child agent's scoped creation context. * @param composition - the persona and tool filter to install. */ export function applyChildComposition(childCtx: Context, composition: ChildComposition): void { + // After sandbox:policy (110) and approval:policy (115): scope, then policy, + // then what a delegated child does about a denial. + childCtx.systemPrompt.context({ name: 'subagent:delegation', order: 120, text: SUBAGENT_DELEGATION_CONTEXT }) if (composition.persona !== undefined) { childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona }) } if (composition.toolFilter !== undefined) childCtx.tools.restrict(composition.toolFilter) } -/** Parent-session policy overrides captured at the delegation boundary. */ +/** Policy seeded onto a child session's log at the delegation boundary. */ export interface DelegatedPolicyOverrides { /** The parent session's explicit sandbox-mode override, or `undefined` without one. */ readonly sandboxMode: SandboxMode | undefined - /** The parent session's explicit approval-policy override, or `undefined` without one. */ - readonly approvalPolicy: ApprovalPolicy | undefined + /** + * The child's pinned approval policy, or `undefined` when no approval + * capability is composed. Always `'never'` with one composed: a delegated + * child acts only within the sandbox scope fixed at delegation, so the + * composed `ApprovalService` rejects every child ask deterministically + * instead of waiting on a prompt no one is watching. + */ + readonly approvalPolicy: 'never' | undefined } /** - * Capture the parent session's explicit policy overrides for one delegation. - * Call synchronously before the child start's first await: a later parent - * switch belongs to the parent's future, not to this child. Deployment - * defaults and one-shot grants are never captured, so an unswitched parent - * leaves the child following the deployment default dynamically. + * Capture the policy to seed into one delegation. Call synchronously before + * the child start's first await: a later parent switch belongs to the + * parent's future, not to this child. The sandbox scope is the parent + * session's explicit override — deployment defaults and one-shot grants are + * never captured, so an unswitched parent leaves the child following the + * deployment default dynamically. The approval policy is never inherited: it + * is pinned to `'never'` whenever the approval capability is composed, + * regardless of the parent's own policy. * @param parent - the delegating parent agent. - * @returns the overrides to seed into the child, each `undefined` without one. + * @returns the sandbox override (or `undefined` without one) and the approval pin. */ export function captureDelegatedPolicyOverrides(parent: Agent): DelegatedPolicyOverrides { return { sandboxMode: parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session), - approvalPolicy: parent.ctx.get('approval')?.overrideOf(parent.session), + approvalPolicy: parent.ctx.get('approval') === undefined ? undefined : 'never', } } /** - * Append captured parent overrides onto the child's own log as + * Append the captured delegation policy onto the child's own log as * `source: 'delegation'` events inside the unpublished creation window, so the * child's effective policy is reconstructable from its log alone. Appends land * after any fork seed, so fresh policy wins stale seed state; later child * switches still win over these events. * @param childSession - the unpublished child's session. - * @param overrides - the overrides captured at delegation. + * @param overrides - the policy captured at delegation. */ export function appendDelegatedPolicyOverrides( childSession: Session, diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 743d6d63de..2f54f90218 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -214,8 +214,8 @@ interface MaterializeInputs { create?: { seed: readonly SessionEvent[] meta: NonNullable<CreateAgentOptions['meta']> - /** Parent policy overrides captured at the delegation boundary. */ - inheritedPolicies: DelegatedPolicyOverrides + /** Policy captured at the delegation boundary: the parent's sandbox override plus the approval pin. */ + delegatedPolicies: DelegatedPolicyOverrides } agentOptions: AgentOptions composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } @@ -355,7 +355,7 @@ export class SubagentContinuationManager { }) // Capture before the first await: a later parent switch belongs to the // parent's future, not to this child. - const inheritedPolicies = captureDelegatedPolicyOverrides(parent) + const delegatedPolicies = captureDelegatedPolicyOverrides(parent) const prepared = await this.host.prepareContinuable(spec.provider, { sessionId: childId, @@ -372,7 +372,7 @@ export class SubagentContinuationManager { childId, provider: spec.provider, parent, - create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), inheritedPolicies }, + create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), delegatedPolicies }, agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, @@ -900,11 +900,11 @@ export class SubagentContinuationManager { // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() const setup = (childCtx: Context): AgentSetupCommit => { - // Only fresh creation seeds captured parent policy onto the child's own + // Only fresh creation seeds the delegation policy onto the child's own // log (after any fork seed, so fresh policy wins stale seed state); a // cold resume replays those persisted events instead. if (create !== undefined) { - appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, create.inheritedPolicies) + appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, create.delegatedPolicies) } applyChildComposition(childCtx, inputs.composition) return this.setupRegistry.apply(childCtx) diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index 92cefa261e..1e539c28c2 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -1,9 +1,9 @@ /** - * Continuable-child policy inheritance: a fresh continuable start seeds the - * parent's explicit sandbox/approval overrides onto the child's own log as - * `source: 'delegation'` events, and a cold resume replays that persisted - * snapshot instead of re-capturing the parent (the one-shot - * `subagent-inprocess/tests/inheritance.spec.ts` counterpart). + * Continuable-child delegation policy: a fresh continuable start seeds the + * parent's explicit sandbox override and the pinned `approval/policy: never` + * onto the child's own log as `source: 'delegation'` events, and a cold + * resume replays that persisted snapshot instead of re-capturing the parent + * (the one-shot `subagent-inprocess/tests/inheritance.spec.ts` counterpart). */ import { afterEach, describe, expect, it, vi } from 'vitest' @@ -21,7 +21,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' -import ApprovalService, { effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import ApprovalService, { effectiveApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentService from '../src/index.ts' @@ -71,10 +71,12 @@ function policyEvents(events: readonly SessionEvent[]) { } describe('continuable policy inheritance', () => { - it('seeds parent overrides into a fresh continuable child', async () => { + it('seeds the parent sandbox override and pins approval to never', async () => { const { ctx, parent } = await setup([textResponse('child done')]) setSandboxMode(parent.session, 'danger-full-access') - setApprovalPolicy(parent.session, 'never') + // The parent keeps the interactive deployment default: the child pin must + // not depend on any parent approval override. + expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() let child: Agent | undefined ctx.on('agent/created', ({ agent }) => { if (agent !== parent) child = agent @@ -93,9 +95,20 @@ describe('continuable policy inheritance', () => { { type: 'sandbox/mode', data: { mode: 'danger-full-access', source: 'delegation' } }, { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, ]) - // Durable: a reload folds the same effective policy. + // Durable: a reload folds the same effective policy; the parent keeps its own. expect(effectiveSandboxMode(loaded.events)).toBe('danger-full-access') expect(effectiveApprovalPolicy(loaded.events)).toBe('never') + expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() + // The child's runtime-context snapshot states the fixed delegation scope. + const runtimeContext = loaded.events.find( + (event): event is SessionEvent<'user/message'> => event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt', + ) + const contextText = runtimeContext?.data.content + .flatMap(block => block.type === 'text' ? [block.text] : []) + .join('\n') + expect(contextText).toContain('You are a delegated subagent') }) it('captures policy at delegation before asynchronous child creation', async () => { @@ -114,17 +127,20 @@ describe('continuable policy inheritance', () => { expect(effectiveSandboxMode(loaded.events)).toBe('read-only') }) - it('does not freeze deployment defaults into an unswitched child', async () => { + it('leaves an unswitched sandbox on the deployment default while still pinning approval', async () => { const { ctx, parent } = await setup([textResponse('child done')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - expect(policyEvents(loaded.events)).toEqual([]) + expect(policyEvents(loaded.events)).toMatchObject([ + { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, + ]) + expect(effectiveSandboxMode(loaded.events)).toBeUndefined() }) - it('does not freeze deployment defaults into an unswitched fork child either', async () => { + it('pins approval after the fork prefix of an unswitched fork child', async () => { const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('forked child')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent work' }], @@ -137,7 +153,10 @@ describe('continuable policy inheritance', () => { const loaded = await ctx.sessionPersistence.load(started.childId) expect(loaded.meta.seedLength).toBeGreaterThan(0) - expect(policyEvents(loaded.events)).toEqual([]) + expect(policyEvents(loaded.events)).toMatchObject([ + { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, + ]) + expect(effectiveSandboxMode(loaded.events)).toBeUndefined() }) it('lets a later child-side switch win over the delegation snapshot', async () => { @@ -180,6 +199,10 @@ describe('continuable policy inheritance', () => { { data: { mode: 'read-only', source: 'delegation' } }, ]) expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + // The approval pin is seeded once at creation, never re-appended on resume. + expect(loaded.events.filter(event => event.type === 'approval/policy')).toMatchObject([ + { data: { policy: 'never', source: 'delegation' } }, + ]) }) it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => { diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 9370676f76..6e23f6ccae 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -103,9 +103,9 @@ function hasUserText(events: readonly SessionEvent[], text: string): boolean { && event.data.content.some(block => block.type === 'text' && block.text === text)) } -/** Every user-role message text in log order, for FIFO assertions. */ +/** Every caller-supplied user-role message text in log order, for FIFO assertions (framework runtime-context snapshots excluded). */ function userTexts(events: readonly SessionEvent[]): string[] { - return events.flatMap(event => event.type === 'user/message' + return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) } diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index c5ea8fd1b8..462b769e94 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -158,7 +158,7 @@ describe('dsh-tool-subagent-control', () => { await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - const prompts = loaded.events.flatMap(event => event.type === 'user/message' + const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) // A follow-up is its own later turn, never steering inside the first one. @@ -274,7 +274,7 @@ describe('dsh-tool-subagent-control interrupt_agent', () => { expect(waking.isError).toBe(false) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - const prompts = loaded.events.flatMap(event => event.type === 'user/message' + const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) expect(prompts).toEqual(['long work', 'parked follow-up', 'wake up']) diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 23757c2b54..c74fb94646 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -411,9 +411,9 @@ describe('dsh-tool-subagent-report', () => { }) }) -/** Prove report delivery uses ordinary logged user messages. */ +/** Prove report delivery uses ordinary logged user messages (framework runtime-context snapshots excluded). */ function userTexts(events: readonly SessionEvent[]): string[] { - return events.flatMap(event => event.type === 'user/message' + return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 0a202e6142..2b962ca471 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -133,7 +133,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = { 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, 'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' }, 'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' }, - 'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' }, 'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' }, 'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' }, 'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' }, From 08b85654f437698f393d2a8e4c466f53440374c5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 19:28:02 +0800 Subject: [PATCH 105/229] feat(web): localize shipped agent presets --- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 + packages/client/ui-agent-preset/README.zh.md | 2 + .../src/client/AgentPresetLabel.tsx | 6 +- .../src/client/AgentPresetRow.tsx | 12 +-- .../src/client/AgentPresetSeat.tsx | 29 +++++--- .../src/client/AgentPresetSection.tsx | 33 ++++++--- .../ui-agent-preset/src/client/PresetMenu.tsx | 25 ++++--- .../ui-agent-preset/src/client/locales.ts | 73 +++++++++++++++++++ .../ui-agent-preset/tests/components.spec.tsx | 22 ++++-- .../ui-agent-preset/tests/locales.spec.ts | 33 +++++++++ .../ui-agent-preset/tests/section.spec.tsx | 32 ++++---- 12 files changed, 209 insertions(+), 64 deletions(-) create mode 100644 packages/client/ui-agent-preset/tests/locales.spec.ts diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index b1314b349e..a7377027a2 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: 0f1daeaa6014d4c3c88e6a69ff90cf1ecacdbaf7 -README.zh.md: 08e25d9e98b83a94a434248bb3dff60da1cc31ba +README.md: c4c7df4e6fbe0479cac4767247c1b10fd65aad77 +README.zh.md: 84c02977ec18f89c06311b570428f92c2b459fb3 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 0f1daeaa60..c4c7df4e6f 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -26,6 +26,8 @@ Options and the current default both come from one `agentPreset.list` call. The A locally authored preset is exactly as privileged as the plugins it names, so the list marks `user` rows rather than presenting every preset as shipped and vetted. +Preset files publish one unlocalized `name` and `description`, which Web uses for every `user` row and unknown `system` row. For the four shipped ids (`standard`, `code`, `minimal`, and `cordis`), Web resolves both fields from its active locale only when the roster marks the row `system`; an identically named `user` preset keeps its file metadata. + The row re-reads on `settings/changed` for its own namespace and on `connection/reset`: the roster is a live directory and the default is a settings field, so an external edit or a reconnect can both move it. ## The management section diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 08e25d9e98..84c02977ec 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -26,6 +26,8 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于 本地创作的 preset 的权限恰好等于它所引用的插件,因此列表会标注 `user` 行,而不是把每个 preset 都呈现为随附且已审核的。 +preset 文件提供一套未国际化的 `name` 与 `description`,Web 将其用于所有 `user` 行和未知的 `system` 行。对于四个随附 id(`standard`、`code`、`minimal` 与 `cordis`),只有名单将该行标记为 `system` 时,Web 才会从当前 locale 解析这两个字段;同名的 `user` preset 仍使用其文件元数据。 + 本行在自身命名空间的 `settings/changed` 以及 `connection/reset` 时重新读取:名单是一个活动目录,默认值是一项设置,外部编辑与重新连接都可能改变它。 ## 管理分区 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index 82688dd7c2..517a856e9a 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -15,6 +15,7 @@ import { IconThinkOutline16 } 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' +import { presetDisplayText } from './locales.ts' import css from './AgentPresetLabel.module.css' /** Registration-side business face for the header label. */ @@ -53,10 +54,11 @@ export function AgentPresetLabel({ if (preset === undefined) return null const option = options.find(entry => entry.id === preset) + const text = option === undefined ? undefined : presetDisplayText(option, t) return ( - <span className={css.label} title={option?.description ?? t('headerHint')}> + <span className={css.label} title={text?.description ?? t('headerHint')}> <IconThinkOutline16 className={css.icon} /> - {option?.name ?? preset} + {text?.name ?? preset} </span> ) } diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx index ba875b0b95..eab363122c 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx @@ -8,7 +8,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 type { AgentPresetSettingsState } from './settings-store.ts' -import type { AgentPresetSettingsKey } from './locales.ts' +import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' import { PresetMenu } from './PresetMenu.tsx' import css from './AgentPresetRow.module.css' @@ -52,11 +52,11 @@ export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetR // every session shares the host composition — the row simply does not exist. if (state.status === 'unavailable') return null const busy = state.status === 'loading' || state.status === 'saving' - // The metadata name is what every other surface shows — the id is the - // addressing, not the label. A preset that names itself nothing falls back - // to its id, which is then all there is to say about it. + // Every preset surface applies the same display-copy rule. The id remains + // addressing rather than a label, except where no display name exists. const chosen = state.options.find(option => option.id === state.currentValue) - const label = state.currentValue === '' ? t('loading') : (chosen?.name ?? state.currentValue) + const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t) + const label = state.currentValue === '' ? t('loading') : (chosenText?.name ?? state.currentValue) const description: string = state.error ?? t('description') return ( @@ -69,7 +69,7 @@ export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetR options={state.options} selectedId={state.currentValue} label={label} - userTrustLabel={t('userTrust')} + t={t} buttonClassName={css.selector} chevronClassName={css.chevron} disabled={busy || !state.writable || state.options.length === 0} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index 8e18471fbc..f4357870bb 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -19,6 +19,7 @@ import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai // 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' +import { presetDisplayText } from './locales.ts' import css from './AgentPresetSeat.module.css' /** Registration-side business face for the hero chip. */ @@ -57,22 +58,26 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr 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) return ( <Menu open={open} onClose={() => { setOpen(false) }} - items={state.options.map(option => ({ - id: option.id, - // Name and description together: the id alone never said what a - // preset does, which is the whole reason the metadata exists. - label: ( - <span className={css.item}> - <span className={css.itemName}>{option.name ?? option.id}</span> - <span className={css.itemDesc}>{option.description ?? t('noDescription')}</span> - </span> - ), - }))} + items={state.options.map((option) => { + const text = presetDisplayText(option, t) + return { + id: option.id, + // Name and description together: the id alone never says what a + // preset does, which is why the roster carries display copy. + label: ( + <span className={css.item}> + <span className={css.itemName}>{text.name}</span> + <span className={css.itemDesc}>{text.description ?? t('noDescription')}</span> + </span> + ), + } + })} selectedId={state.current} onSelect={(id) => { setOpen(false) @@ -91,7 +96,7 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr onClick={() => { setOpen(value => !value) }} > <IconThinkOutline16 className={css.seatIcon} /> - {chosen?.name ?? state.current} + {chosenText?.name ?? state.current} <IconChevronDownOutline14 className={css.chevron} /> </button> )} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 3a9d0b960a..f5a31fcdf8 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -18,7 +18,7 @@ import { import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { draftBlocker, type AgentPresetSectionState } from './section-store.ts' -import type { AgentPresetSettingsKey } from './locales.ts' +import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' import css from './AgentPresetSection.module.css' /** Registration-side business face for the management section. */ @@ -77,11 +77,13 @@ function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode { const draft = state.copy const blocker = draft === null ? undefined : draftBlocker(draft, state.rows) const message = draft === null ? null : draft.error ?? (blocker === undefined ? null : t(blocker)) + const source = draft === null ? undefined : state.rows.find(row => row.id === draft.from) + const sourceTitle = source === undefined ? draft?.fromTitle : presetDisplayText(source, t).name return ( <Modal open={draft !== null} onClose={() => { actions.cancelCopy() }} - title={draft === null ? t('copyTitle') : `${t('copyTitle')} · ${t('copyOf')} ${draft.fromTitle}`} + title={draft === null ? t('copyTitle') : `${t('copyTitle')} · ${t('copyOf')} ${sourceTitle}`} closeLabel={t('close')} description={t('copyIntro')} className={css.dialog as string} @@ -143,6 +145,11 @@ function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode { export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { const { useAgentPresetSection, t, load } = props const state = useAgentPresetSection(snapshot => snapshot) + const viewedId = state.view?.id + const viewedRow = viewedId === undefined ? undefined : state.rows.find(row => row.id === viewedId) + const viewedTitle = state.view === null + ? '' + : viewedRow === undefined ? state.view.title : presetDisplayText(viewedRow, t).name useEffect(() => { void load() @@ -170,13 +177,15 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { <p className={css.intro}>{t('sectionIntro')}</p> {state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>} {([['system', t('builtInGroup')], ['user', t('customGroup')]] as const).map(([trust, heading]) => { - const group = state.rows.filter(row => row.trust === trust) + const group = state.rows + .filter(row => row.trust === trust) + .map(row => ({ row, text: presetDisplayText(row, t) })) if (group.length === 0) return null return ( <section key={trust} className={css.group}> <h3 className={css.groupHead}>{heading}</h3> <ul className={css.cards}> - {group.map(row => ( + {group.map(({ row, text }) => ( <li key={row.id} className={row.broken !== undefined @@ -196,12 +205,12 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { disabled={row.isDefault || row.broken !== undefined} // Without this the name is the whole card read aloud — // title, badge, description, id. - aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${row.name ?? row.id}`} + aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`} title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))} onClick={() => { void props.makeDefault(row.id) }} > <span className={css.cardHead}> - <span className={css.cardName}>{row.name ?? row.id}</span> + <span className={css.cardName}>{text.name}</span> {row.broken !== undefined ? <span className={css.brokenBadge}>{t('brokenBadge')}</span> : null} @@ -210,7 +219,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { </span> {row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null} </span> - <span className={css.cardDesc}>{row.description ?? t('noDescription')}</span> + <span className={css.cardDesc}>{text.description ?? t('noDescription')}</span> {row.broken === undefined ? null : <span className={css.cardBrokenReason} role="alert">{row.broken}</span>} @@ -231,7 +240,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { type="button" className={css.iconButton} data-tip={t('view')} - aria-label={`${t('view')}: ${row.name ?? row.id}`} + aria-label={`${t('view')}: ${text.name}`} onClick={() => { void props.view(row.id) }} > <IconBrowseOutline16 /> @@ -243,7 +252,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { type="button" className={css.iconButton} data-tip={state.hasDocument ? t('openLocation') : t('showLocation')} - aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${row.name ?? row.id}`} + aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`} onClick={() => { void props.openLocation(row.id) }} > <IconFolderOpenOutline16 /> @@ -256,7 +265,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { data-tip={row.broken !== undefined ? t('brokenNoCopy') : state.authorable ? t('duplicate') : t('duplicateUnavailable')} - aria-label={`${t('duplicate')}: ${row.name ?? row.id}`} + aria-label={`${t('duplicate')}: ${text.name}`} onClick={() => { props.beginCopy(row.id) }} > <IconCopyOutline16 /> @@ -267,7 +276,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { type="button" className={`${css.iconButton} ${css.iconDanger}`} data-tip={t('delete')} - aria-label={`${t('delete')}: ${row.name ?? row.id}`} + aria-label={`${t('delete')}: ${text.name}`} onClick={() => { props.confirmDelete(row.id) }} > <IconTrashOutline16 /> @@ -325,7 +334,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { <Modal open={state.view !== null} onClose={() => { props.closeView() }} - title={state.view === null ? '' : `${t('view')} · ${state.view.title}`} + title={state.view === null ? '' : `${t('view')} · ${viewedTitle}`} closeLabel={t('close')} description={t('composition')} className={css.dialog as string} diff --git a/packages/client/ui-agent-preset/src/client/PresetMenu.tsx b/packages/client/ui-agent-preset/src/client/PresetMenu.tsx index 2a6bc6ea28..4b78d8ce6e 100644 --- a/packages/client/ui-agent-preset/src/client/PresetMenu.tsx +++ b/packages/client/ui-agent-preset/src/client/PresetMenu.tsx @@ -11,6 +11,7 @@ import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { AgentPresetOption } from './settings-store.ts' +import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' /** What one surface passes to the shared picker. */ export interface PresetMenuProps { @@ -20,8 +21,8 @@ export interface PresetMenuProps { selectedId: string /** Text on the button; the surfaces word a pending roster differently. */ label: string - /** Suffix marking a locally authored preset in the menu. */ - userTrustLabel: string + /** Active Web locale lookup. */ + t: (key: AgentPresetSettingsKey) => string /** Class for the trigger button, owned by the calling surface. */ buttonClassName: string | undefined /** Class for the chevron, owned by the calling surface. */ @@ -42,22 +43,22 @@ export interface PresetMenuProps { * @returns the menu and its trigger. */ export function PresetMenu({ - options, selectedId, label, userTrustLabel, buttonClassName, chevronClassName, + options, selectedId, label, t, buttonClassName, chevronClassName, disabled, open, onOpenChange, onSelect, }: PresetMenuProps) { return ( <Menu open={open} onClose={() => { onOpenChange(false) }} - items={options.map(option => ({ - id: option.id, - // The metadata name is what every surface shows; the id is addressing, - // not a label. A preset that names itself nothing falls back to its id, - // which is then all there is to say about it. - label: option.trust === 'user' - ? `${option.name ?? option.id} · ${userTrustLabel}` - : option.name ?? option.id, - }))} + items={options.map((option) => { + const name = presetDisplayText(option, t).name + return { + id: option.id, + // All preset surfaces resolve copy the same way; the id is addressing, + // not a label, except where no display name exists. + label: option.trust === 'user' ? `${name} · ${t('userTrust')}` : name, + } + })} selectedId={selectedId} onSelect={(id) => { onOpenChange(false) diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 50a4e36138..7d7453837f 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -4,6 +4,10 @@ export type AgentPresetSettingsKey = | 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint' | 'nav' | 'sectionIntro' | 'builtIn' | 'setDefault' | 'view' + | 'presetStandardName' | 'presetStandardDescription' + | 'presetCodeName' | 'presetCodeDescription' + | 'presetMinimalName' | 'presetMinimalDescription' + | 'presetCordisName' | 'presetCordisDescription' | 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf' | 'displayName' | 'displayNamePlaceholder' | 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup' @@ -30,6 +34,17 @@ export const en: Record<AgentPresetSettingsKey, string> = { builtIn: 'Built-in', setDefault: 'Set as default', view: 'View', + presetStandardName: 'Standard mode', + presetStandardDescription: 'Full coding agent with file editing, shell, search, planning, delegation, and workflows.', + presetCodeName: 'Code mode', + presetCodeDescription: + 'Presents Standard mode\'s tools through Code Mode: the model writes TypeScript against an SDK and runs it once instead of making multiple tool calls.', + presetMinimalName: 'Minimal mode', + presetMinimalDescription: + 'Exposes only bash and str_replace_editor to the model, for benchmarks and minimal reproductions.', + presetCordisName: 'Creator mode', + presetCordisDescription: + 'Adds self-inspection tools to Standard mode, so it can read and modify its own running composition and create new presets from it.', duplicate: 'Duplicate', duplicateUnavailable: 'This deployment has no writable preset directory', delete: 'Delete', @@ -82,6 +97,14 @@ export const zh: Record<AgentPresetSettingsKey, string> = { builtIn: '内置', setDefault: '设为默认', view: '查看', + presetStandardName: '标准模式', + presetStandardDescription: '完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。', + presetCodeName: '代码模式', + presetCodeDescription: '标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。', + presetMinimalName: '极简模式', + presetMinimalDescription: '只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。', + presetCordisName: '创造模式', + presetCordisDescription: '标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。', duplicate: '复制', duplicateUnavailable: '此部署未配置可写的预设目录', delete: '删除', @@ -116,3 +139,53 @@ export const zh: Record<AgentPresetSettingsKey, string> = { deleteConfirm: '删除', deleting: '正在删除…', } + +/** Preset roster fields needed to resolve Web display copy. */ +export interface PresetDisplaySource { + /** Stable preset id. */ + readonly id: string + /** Whether the deployment ships the preset or the user owns it. */ + readonly trust: 'system' | 'user' + /** Unlocalized name published by the preset. */ + readonly name?: string + /** Unlocalized description published by the preset. */ + readonly description?: string +} + +/** Display copy resolved for the active Web locale. */ +export interface PresetDisplayText { + /** Localized built-in name or the preset's own fallback name. */ + readonly name: string + /** Localized built-in description or the preset's own description. */ + readonly description?: string +} + +interface PresetLocaleKeys { + readonly name: AgentPresetSettingsKey + readonly description: AgentPresetSettingsKey +} + +const BUILT_IN_PRESET_KEYS: Readonly<Partial<Record<string, PresetLocaleKeys>>> = { + standard: { name: 'presetStandardName', description: 'presetStandardDescription' }, + code: { name: 'presetCodeName', description: 'presetCodeDescription' }, + minimal: { name: 'presetMinimalName', description: 'presetMinimalDescription' }, + cordis: { name: 'presetCordisName', description: 'presetCordisDescription' }, +} + +/** + * Resolve preset display copy without making user-authored metadata translatable. + * @param preset - roster row whose copy is being rendered. + * @param t - active Web locale lookup. + * @returns localized copy for a known shipped preset, otherwise file metadata. + */ +export function presetDisplayText( + preset: PresetDisplaySource, + t: (key: AgentPresetSettingsKey) => string, +): PresetDisplayText { + const keys = preset.trust === 'system' ? BUILT_IN_PRESET_KEYS[preset.id] : undefined + if (keys !== undefined) return { name: t(keys.name), description: t(keys.description) } + return { + name: preset.name ?? preset.id, + ...preset.description === undefined ? {} : { description: preset.description }, + } +} diff --git a/packages/client/ui-agent-preset/tests/components.spec.tsx b/packages/client/ui-agent-preset/tests/components.spec.tsx index 7bc3d59e04..8a37a7af43 100644 --- a/packages/client/ui-agent-preset/tests/components.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.spec.tsx @@ -90,7 +90,7 @@ describe('the General-settings row', () => { const actions = renderRow() await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) - expect(screen.getByRole('button').textContent).toContain('标准模式') + expect(screen.getByRole('button').textContent).toContain(en.presetStandardName) }) it('marks a locally authored option as local', () => { @@ -102,7 +102,7 @@ describe('the General-settings row', () => { // list says which rows are local rather than presenting all as vetted. expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy() // The shipped one carries no marker; only local rows are called out. - expect(screen.getAllByText('标准模式')).toHaveLength(2) + expect(screen.getAllByText(en.presetStandardName)).toHaveLength(2) }) it('falls back to the id for a preset that published no name', () => { @@ -128,6 +128,12 @@ describe('the General-settings row', () => { expect(screen.getByText('bare')).toBeTruthy() }) + it('shows the selected id until a stale roster contains it', () => { + renderRow({ currentValue: 'arriving', options: [] }) + + expect(screen.getByRole('button').textContent).toContain('arriving') + }) + it('writes the picked preset and closes the menu', () => { const actions = renderRow() fireEvent.click(screen.getByRole('button')) @@ -194,7 +200,7 @@ describe('the new-session chip', () => { const actions = renderSeat() await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) - expect(screen.getByRole('button').textContent).toContain('标准模式') + expect(screen.getByRole('button').textContent).toContain(en.presetStandardName) expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint) }) @@ -205,7 +211,7 @@ describe('the new-session chip', () => { // The id alone never said what a preset does; the description is the // whole reason a preset can publish metadata at all. - expect(screen.getByText('完整的编码 agent。')).toBeTruthy() + expect(screen.getByText(en.presetStandardDescription)).toBeTruthy() // A preset that published none still reads as a row, with its id standing // in for the name. expect(screen.getByText(en.noDescription)).toBeTruthy() @@ -218,6 +224,12 @@ describe('the new-session chip', () => { expect(screen.getByRole('button').textContent).toContain('mine') }) + it('shows the staged id until a stale roster contains it', () => { + renderSeat({ current: 'arriving' }) + + expect(screen.getByRole('button').textContent).toContain('arriving') + }) + it('stages the picked preset and closes the menu', () => { const actions = renderSeat() fireEvent.click(screen.getByRole('button')) @@ -267,7 +279,7 @@ describe('the session-header label', () => { await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) }) // A control here would promise a switch the host refuses outright. expect(screen.queryByRole('button')).toBeNull() - expect(screen.getByTitle('完整的编码 agent。').textContent).toBe('标准模式') + expect(screen.getByTitle(en.presetStandardDescription).textContent).toBe(en.presetStandardName) }) it('falls back to the id, and to the generic hint, when metadata is absent', () => { diff --git a/packages/client/ui-agent-preset/tests/locales.spec.ts b/packages/client/ui-agent-preset/tests/locales.spec.ts new file mode 100644 index 0000000000..02623e7d7b --- /dev/null +++ b/packages/client/ui-agent-preset/tests/locales.spec.ts @@ -0,0 +1,33 @@ +/** Web-localized copy for the four shipped presets and file copy for every other row. */ + +import { describe, expect, it } from 'vitest' +import { en, presetDisplayText, zh } from '../src/client/locales.ts' + +const translate = (bundle: typeof en) => (key: keyof typeof en): string => bundle[key] + +describe('preset display copy', () => { + it.each([ + ['standard', 'presetStandardName', 'presetStandardDescription'], + ['code', 'presetCodeName', 'presetCodeDescription'], + ['minimal', 'presetMinimalName', 'presetMinimalDescription'], + ['cordis', 'presetCordisName', 'presetCordisDescription'], + ] as const)('localizes the shipped %s preset in English and Chinese', (id, nameKey, descriptionKey) => { + const preset = { id, trust: 'system' as const, name: 'file name', description: 'file description' } + + expect(presetDisplayText(preset, translate(en))) + .toEqual({ name: en[nameKey], description: en[descriptionKey] }) + expect(presetDisplayText(preset, translate(zh))) + .toEqual({ name: zh[nameKey], description: zh[descriptionKey] }) + }) + + it('keeps file metadata for user and unknown system presets', () => { + const fileCopy = { name: '我的标准', description: '团队自己的 preset。' } + + expect(presetDisplayText({ id: 'standard', trust: 'user', ...fileCopy }, translate(en))) + .toEqual(fileCopy) + expect(presetDisplayText({ id: 'deployment-extra', trust: 'system', ...fileCopy }, translate(en))) + .toEqual(fileCopy) + expect(presetDisplayText({ id: 'bare', trust: 'user' }, translate(en))) + .toEqual({ name: 'bare' }) + }) +}) diff --git a/packages/client/ui-agent-preset/tests/section.spec.tsx b/packages/client/ui-agent-preset/tests/section.spec.tsx index 36e34067b3..05c2b28d67 100644 --- a/packages/client/ui-agent-preset/tests/section.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.spec.tsx @@ -85,13 +85,13 @@ describe('the preset list', () => { await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) }) - it('shows the published name and description, falling back to the id', () => { + it('shows resolved copy for built-ins and falls back to custom ids', () => { renderSection() - // The name is what a picker reads; the id stays visible as the key the + // Display copy is what a picker reads; the id stays visible as the key the // composition and the session header actually carry. - expect(screen.getByText('标准模式')).toBeTruthy() - expect(screen.getByText('完整的编码 agent。')).toBeTruthy() + expect(screen.getByText(en.presetStandardName)).toBeTruthy() + expect(screen.getByText(en.presetStandardDescription)).toBeTruthy() const mine = rowFor('mine') expect(within(mine).getAllByText('mine').length).toBeGreaterThan(0) expect(within(mine).getByText(en.noDescription)).toBeTruthy() @@ -134,7 +134,7 @@ describe('the preset list', () => { it('picks a preset by clicking its card, and the one in use is inert', () => { const actions = renderSection() - const inUse = within(rowFor('standard')).getByRole('button', { name: `${en.inUse}: 标准模式` }) + const inUse = within(rowFor('standard')).getByRole('button', { name: `${en.inUse}: ${en.presetStandardName}` }) expect(inUse).toHaveProperty('disabled', true) fireEvent.click(inUse) @@ -150,8 +150,8 @@ describe('the preset list', () => { // the point. A custom preset is edited in its files, so its row leads // there instead; there is no editor for either. const standard = rowFor('standard') - expect(within(standard).getByRole('button', { name: `${en.view}: 标准模式` })).toBeTruthy() - expect(within(standard).queryByRole('button', { name: `${en.openLocation}: 标准模式` })).toBeNull() + expect(within(standard).getByRole('button', { name: `${en.view}: ${en.presetStandardName}` })).toBeTruthy() + expect(within(standard).queryByRole('button', { name: `${en.openLocation}: ${en.presetStandardName}` })).toBeNull() const mine = rowFor('mine') expect(within(mine).getByRole('button', { name: `${en.openLocation}: mine` })).toBeTruthy() expect(within(mine).queryByRole('button', { name: `${en.view}: mine` })).toBeNull() @@ -161,13 +161,13 @@ describe('the preset list', () => { renderSection() expect(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` })).toBeTruthy() - expect(within(rowFor('standard')).queryByRole('button', { name: `${en.delete}: 标准模式` })).toBeNull() + expect(within(rowFor('standard')).queryByRole('button', { name: `${en.delete}: ${en.presetStandardName}` })).toBeNull() }) it('disables duplication when nothing is writable, and says why', () => { renderSection({ authorable: false }) - const duplicate = within(rowFor('standard')).getByRole('button', { name: `${en.duplicate}: 标准模式` }) + const duplicate = within(rowFor('standard')).getByRole('button', { name: `${en.duplicate}: ${en.presetStandardName}` }) expect(duplicate).toHaveProperty('disabled', true) expect(duplicate.getAttribute('data-tip')).toBe(en.duplicateUnavailable) }) @@ -205,7 +205,7 @@ describe('the preset list', () => { // There is no readable composition to offer; the reason on the card is // the whole story a shipped row can tell. const standard = rowFor('standard') - expect(within(standard).queryByRole('button', { name: `${en.view}: 标准模式` })).toBeNull() + expect(within(standard).queryByRole('button', { name: `${en.view}: ${en.presetStandardName}` })).toBeNull() expect(within(standard).getByRole('alert').textContent).toContain('not valid YAML') }) @@ -232,7 +232,7 @@ describe('the preset list', () => { fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.setDefault}: mine` })) fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.openLocation}: mine` })) fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.duplicate}: mine` })) - fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.view}: 标准模式` })) + fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.view}: ${en.presetStandardName}` })) expect(actions.makeDefault).toHaveBeenCalledWith('mine') expect(actions.openLocation).toHaveBeenCalledWith('mine') @@ -311,7 +311,7 @@ describe('the copy dialog', () => { const actions = renderSection({ copy: draft }) const dialog = screen.getByRole('dialog') - expect(dialog.getAttribute('aria-label')).toBe(`${en.copyTitle} · ${en.copyOf} 标准模式`) + expect(dialog.getAttribute('aria-label')).toBe(`${en.copyTitle} · ${en.copyOf} ${en.presetStandardName}`) expect(within(dialog).getByText(en.copyIntro)).toBeTruthy() fireEvent.change(within(dialog).getByPlaceholderText(en.presetIdPlaceholder), { target: { value: 'my-agent' } }) fireEvent.change(within(dialog).getByPlaceholderText(en.displayNamePlaceholder), { target: { value: '我的模式' } }) @@ -374,11 +374,17 @@ describe('the read-only viewer', () => { renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: tool-bash\n' } }) const dialog = screen.getByRole('dialog') - expect(dialog.getAttribute('aria-label')).toBe(`${en.view} · 标准模式`) + expect(dialog.getAttribute('aria-label')).toBe(`${en.view} · ${en.presetStandardName}`) expect(within(dialog).getByText(en.composition)).toBeTruthy() expect(within(dialog).getByText(/tool-bash/).textContent).toBe('- id: tool-bash\n') }) + it('keeps the loaded title when the viewed row leaves the roster', () => { + renderSection({ view: { id: 'retired', title: 'Retired mode', content: '- id: tool-bash\n' } }) + + expect(screen.getByRole('dialog').getAttribute('aria-label')).toBe(`${en.view} · Retired mode`) + }) + it('closes through the controller', () => { const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } }) From 2f481fa352dd6c776781f46f1c4bdc0579dc2098 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 19:28:40 +0800 Subject: [PATCH 106/229] fix(apiproxy): echo the preset a created session runs, not its header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `session.create` also adopts an already-live session, and the preceding commit newly allows adopting one under the preset it switched to while blank. Its response still echoed `header.agentPreset`, so that adoption answered with the preset the session had just left — contradicting the request it had accepted and the row `session.list` serves for the same session from `resolveSessionPreset()`. The echo now resolves the same way. The `assertPresetUnchanged` parameter doc said `existing` was the preset the session was created under; both callers now pass what it runs. `composeFrom()` was documented as "infallible" and "cannot fail" beside two `@throws`. It has no composition failure mode — no roster read, no mount, no file — but it does reject a caller error, and the wording now says which. The package-level "switched preset" test re-linked to the same preset id, so it could not tell reading the parent's live scope chain from reading its creation header. A second fixture preset makes the switch real. The Web browser lane's subagent goldens gain the preset badge a child now shows, which is the visible consequence of recording its composition. That lane runs only under DSH_EXAMPLE_MODE=lib and was missed before. The Agent Note records two limits found in review: a cold-resumed continuable child joins its parent's current composition rather than the one its header names, and `toolFilter` does not constrain a joined child. The latter is a regression from the agent-plane move rather than anything this change introduces — with the same tools in the global layer the filter applies normally — and is tracked in #2185. Refs #2185 --- ...d-agents-join-their-parent-preset.i18n.yaml | 4 ++-- ...10-child-agents-join-their-parent-preset.md | 10 ++++++++-- ...child-agents-join-their-parent-preset.zh.md | 10 ++++++++-- .../subagent-conversation/ui.expected.md | 2 ++ .../offline-composer.expected.md | 2 ++ docs/subsystems/core.i18n.yaml | 4 ++-- docs/subsystems/core.md | 6 ++++-- docs/subsystems/core.zh.md | 6 ++++-- packages/host/apiproxy/src/api-proxy.ts | 18 ++++++++++++------ .../tests/api-proxy-agent-preset.spec.ts | 5 +++++ packages/preset/agent-presets/README.i18n.yaml | 4 ++-- packages/preset/agent-presets/README.md | 2 +- packages/preset/agent-presets/README.zh.md | 2 +- packages/preset/agent-presets/src/index.ts | 6 ++++-- .../tool-cordis/src/api-catalog.ts | 2 +- .../presets/reviewing/agent.cordis.yml | 6 ++++++ .../tests/preset-inheritance.spec.ts | 8 ++++++-- 17 files changed, 70 insertions(+), 27 deletions(-) create mode 100644 packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml index 9afec2a879..34697cd123 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-child-agents-join-their-parent-preset.md -2026-08-10-child-agents-join-their-parent-preset.md: c9917c48d10c2b2515284405ea52aed8b476f8b1 -2026-08-10-child-agents-join-their-parent-preset.zh.md: 09e4de5292b65e50bb3973704fd803c819be9f1c +2026-08-10-child-agents-join-their-parent-preset.md: d9aa0dc43c1338d3198f5335da6ad238730d58a1 +2026-08-10-child-agents-join-their-parent-preset.zh.md: dd85c642ff7e6e2934e805c2efaccdc6dda63f15 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md index c9917c48d1..d9aa0dc43c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md @@ -38,10 +38,16 @@ This is a bind, not a mount, and both differences are load-bearing. The child ge `packages/preset/agent-presets/tests/mount.spec.ts` covers the join against real fixture compositions: the child sees its parent's tools and prompt sections, no second generation is mounted, the join survives the parent's disposal (a background child outliving its parent), the reported id matches, a parent without a preset joins nothing, and an unscoped context is refused. -`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, and a parent that switched preset while blank. +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, and a parent that switched preset while blank — to a DIFFERENT preset, so the assertion distinguishes reading the parent's live scope chain from reading its creation header. + +The assembled-transcript layer is the shipped Web composition's e2e rather than a keyless snapshot. Every runnable example this repo ships composes no preset roster, so the defect is not observable in the snapshot harness at all: a snapshot scenario would first need an example that mounts a roster AND delegates. The Web e2e boots the real `base` + `web-app` patch layers with both shipped presets, which is the assembled evidence the testing policy asks for; the Web browser lane's subagent goldens carry the visible consequence, since a child that records its preset now shows the preset badge its parent shows. ## Consequences -Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's, minus whatever its own `toolFilter` removes; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join. +Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's — the per-child `toolFilter` does not narrow them, for the separately tracked reason below; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join. `applyChildComposition()` changed shape, so any future out-of-tree in-process driver must supply the parent. That is the intended cost: the previous signature let a caller compose a capability-less child and get no error. + +A cold-resumed continuable child joins its parent's CURRENT composition rather than the one its own header records. The window is narrow — the parent must create the child, stay blank, switch preset, and only then wake it, since a resident child never re-joins and a one-shot child never resumes — and the alternative is worse: resolving the child's own recorded id would re-read the roster and hand back the preset-deleted failure mode this join exists to avoid. The child's header still records what it started under, so the divergence is observable rather than silent. + +`toolFilter` does not constrain a joined child, because `ToolRegistry` compiles restrictions against global-layer names only and overlays chain-layer tools unfiltered. That is not new here — with the roster composed, `tools.restrict()` already rejected every name as an unknown global tool, so a child carrying a filter failed to start both before and after this change — but it is a regression from the agent-plane move rather than a standing limitation: with the same tools registered in the global layer, the filter admits and applies normally. It matters more now that the child has its parent's full tool set to be restricted from. It is tracked separately; this change neither introduces nor repairs it. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md index 09e4de5292..dd85c642ff 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md @@ -38,10 +38,16 @@ Status: implemented `packages/preset/agent-presets/tests/mount.spec.ts` 用真实 fixture 组装覆盖该加入:子 agent 看到父方的工具与提示段、不会挂载出第二个代际、加入在父方 dispose 后依然成立(活得比父方久的后台子 agent)、上报的 id 一致、没有 preset 的父方不产生加入、以及无 scope 的上下文被拒绝。 -`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset,以及在空白期切换过 preset 的父方。 +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset,以及在空白期切换过 preset 的父方——切换到**另一个** preset,这样断言才能区分"读父方活 scope 链"与"读父方创建 header"。 + +组装记录这一层用的是真实 shipped Web 组装的 e2e,而不是无密钥快照。本仓库所有可运行 example 都不组装 preset roster,因此该缺陷在快照 harness 里根本不可观察:要做快照场景,得先有一个既挂载 roster 又发起委派的 example。Web e2e 启动的是真实的 `base` + `web-app` 补丁层与两个 shipped preset,这正是测试政策要求的组装证据;Web 浏览器 lane 的 subagent golden 承载了可见后果——记录了 preset 的子 agent 现在会显示与其父方相同的 preset 徽标。 ## Consequences -委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力,减去它自己的 `toolFilter` 所移除的部分;逐 subagent 的 preset("agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。 +委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力——逐子 agent 的 `toolFilter` 并不能收窄它,原因见下方另行跟踪的那条;逐 subagent 的 preset("agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。 `applyChildComposition()` 的形态变了,因此将来任何仓库外的进程内驱动都必须提供父方。这是刻意付出的代价:此前的签名允许调用方组装出一个毫无能力的子 agent 而不报任何错。 + +冷恢复的可继续子 agent 加入的是父方**当前**的组装,而不是它自己 header 所记录的那份。窗口很窄——父方必须先建子、保持空白、切换 preset,之后才唤醒它;驻留中的子 agent 不会重新加入,一次性子 agent 也不会恢复——而替代方案更糟:按子 agent 自己记录的 id 解析会重读 roster,把这次认父刻意规避掉的"preset 已删除"失败模式又请回来。子 agent 的 header 仍记录它启动时的那份,因此这处分歧是可观察的而非静默的。 + +`toolFilter` 约束不住已加入组装的子 agent,因为 `ToolRegistry` 只按全局层的名字编译限制,随后把 scope 链上的工具无过滤地叠加进来。这不是本次改动带来的——在组装了 roster 的部署里,`tools.restrict()` 本就把每个名字都判为未知全局工具,因此带过滤器的子 agent 在本次改动前后同样起不来——但它是搬到 agent 平面所引入的回归,而非长期存在的限制:同样这批工具注册在全局层时,过滤器能正常校验并生效。现在子 agent 有了父方的全套工具需要被限制,它变得更要紧。该问题另行跟踪;本次改动既未引入也未修复它。 diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 27c7ec092e..4b71dfdc9c 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -6,6 +6,8 @@ - button "1 subagent": - text: 1 subagent - img + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md index a977afbbea..fbec36baea 100644 --- a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md +++ b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md @@ -3,6 +3,8 @@ - button "Ask a research subagent to" - text: / - button "event-sourcing researcher" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 947df5c3ae..a7d26cee1b 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: 59c66fdacb369dac1968c2e4fbd2ad70f907d3e9 -core.zh.md: 3b6fc13d0fb54b4e18d7c1bf9849509b1947208b +core.md: ad00c4da7d77b0e1ab4728173b202ebc17fb56a0 +core.zh.md: 9c606023c85369643e7148f829526b1f75ea3631 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 59c66fdacb..ad00c4da7d 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -431,9 +431,11 @@ async mount(agentCtx: Context, id?: string): Promise<AgentPreset> * parent's history was produced under (and a preset deleted since would fail * the child outright while its parent keeps running). * - * Synchronous and infallible for that reason, which is what lets a child + * Synchronous, and with no composition failure mode of its own — it reads no + * roster, mounts nothing, and touches no file — which is what lets a child * creation window use it: the two in-process subagent drivers compose their - * children inside a synchronous `setup`. + * children inside a synchronous `setup`. It still rejects a caller error, as + * the `@throws` below record. * * A parent that joined no preset — a rosterless deployment — yields no join * and no error: there, the model-facing rows sit in the host composition and diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 3b6fc13d0f..9c606023c8 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -439,9 +439,11 @@ async mount(agentCtx: Context, id?: string): Promise<AgentPreset> * parent's history was produced under (and a preset deleted since would fail * the child outright while its parent keeps running). * - * Synchronous and infallible for that reason, which is what lets a child + * Synchronous, and with no composition failure mode of its own — it reads no + * roster, mounts nothing, and touches no file — which is what lets a child * creation window use it: the two in-process subagent drivers compose their - * children inside a synchronous `setup`. + * children inside a synchronous `setup`. It still rejects a caller error, as + * the `@throws` below record. * * A parent that joined no preset — a rosterless deployment — yields no join * and no error: there, the model-facing rows sit in the host composition and diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a1f98d4f28..51a2ca8b34 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1038,7 +1038,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * common paths — reconnecting, resuming, retrying a create — are unaffected. * @param sessionId - the identity being adopted. * @param requested - the preset the request named, if any. - * @param existing - the preset the session was created under, if any. + * @param existing - the preset the session RUNS, if any; both callers resolve + * it from the log, which differs from the creation header once a blank + * session has switched. * @throws when both are present and differ. */ function assertPresetUnchanged( @@ -1989,12 +1991,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } } - // Echo the RESOLVED composition so a client can label the session it - // just created without waiting for the next list refresh — the create - // is the commit point that knows it (a caller that named none gets - // the default the header recorded). + // Echo the composition the session RUNS so a client can label it + // without waiting for the next list refresh — the create is the commit + // point that knows it (a caller that named none gets the default). + // Resolved from the log for the same reason `sessionListFields()` is: + // this handler also adopts an already-live session, and one that + // switched while blank runs a preset its header no longer names, so + // echoing the header would contradict both the adoption this call just + // allowed and the row `session.list` serves for the same session. const created = ctx.agents.get(sessionId) - const createdPreset = created?.session.header.agentPreset + const createdPreset = created === undefined ? undefined : resolveSessionPreset(created.session) return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } }) }, diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 996af59986..106cb213da 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -199,6 +199,11 @@ describe('session.create with an agent preset', () => { // Comparing against the header would invert both answers: the preset the // session actually runs would be refused, and the one it left would pass. expect(adopted.result.ok).toBe(true) + // The echo has to name the same preset the adoption just accepted, or the + // client labels the session with one it has already left — and disagrees + // with the row `session.list` serves for it. + if (!adopted.result.ok) throw new Error('unreachable') + expect(adopted.result.value).toMatchObject({ agentPreset: 'minimal' }) expect(stale.result.ok).toBe(false) if (stale.result.ok) throw new Error('unreachable') expect(stale.result.error.details).toMatchObject({ existingPreset: 'minimal' }) diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 9c7f2c54ad..8751d2a8f6 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: 5ccf1d7b224d0e3a67b3aeb9dc6679d6e802063f -README.zh.md: ed79cf48b96ed927feec8860b6211cedc369cdda +README.md: 250d2a6560e680aee5d3088834d5db220854d1d4 +README.zh.md: bd02327a3cca01bc794d63c8933b61bfa5c1008b diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 5ccf1d7b22..250d2a6560 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -14,7 +14,7 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal - `ctx.agentPresets.list(): Promise<AgentPreset[]>` Every preset the configured roots currently supply, earlier root winning a duplicate id; broken presets included, each carrying its reason. - `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. A broken preset resolves — deleting, reading, and reporting one all need the row. - `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved. -- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and cannot fail. +- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and has no composition failure mode; it still rejects a caller error (an unscoped context, or an agent that already joined). - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built. - `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`. - `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index ed79cf48b9..bd02327a3c 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -14,7 +14,7 @@ - `ctx.agentPresets.list(): Promise<AgentPreset[]>` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出;损坏的 preset 也在其中,各自携带原因。 - `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。损坏的 preset 照样解析——删除、读取与上报都需要这一行。 - `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。对损坏的 preset 直接以发现时记下的原因拒绝,所以每种不可加载的形态都在加载器介入之前以同一方式失败。 -- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` 让一个 agent 加入另一个 agent 已在运行的常驻组装,返回所加入的 preset id——父方未加入任何 preset 时返回 `undefined`,那是无 roster 的部署,不是错误。这是认父而非挂载,因此同步且不会失败。 +- `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` 让一个 agent 加入另一个 agent 已在运行的常驻组装,返回所加入的 preset id——父方未加入任何 preset 时返回 `undefined`,那是无 roster 的部署,不是错误。这是认父而非挂载,因此同步、且自身没有组装失败模式;调用方用错(上下文无 scope、agent 已加入过)仍会拒绝。 - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。 - `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。 diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 0fb428c425..1dde902234 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -250,9 +250,11 @@ export class AgentPresets extends Service { * parent's history was produced under (and a preset deleted since would fail * the child outright while its parent keeps running). * - * Synchronous and infallible for that reason, which is what lets a child + * Synchronous, and with no composition failure mode of its own — it reads no + * roster, mounts nothing, and touches no file — which is what lets a child * creation window use it: the two in-process subagent drivers compose their - * children inside a synchronous `setup`. + * children inside a synchronous `setup`. It still rejects a caller error, as + * the `@throws` below record. * * A parent that joined no preset — a rosterless deployment — yields no join * and no error: there, the model-facing rows sit in the host composition and diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 4cc39f662b..69fac20609 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -112,7 +112,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'composeFrom(agentCtx: Context, parentCtx: Context): string | undefined', - jsDoc: '/**\n * Join one agent to the SAME standing composition another already runs on.\n *\n * This is how a child agent inherits its parent\'s capabilities. It is a bind,\n * not a mount: the parent\'s generation is already composed, so the child gets\n * that exact instance — the same plugin objects, the same tool registrations,\n * the same prompt sections. Re-resolving the parent\'s preset by id instead\n * would re-read the roster, and a composition file edited since the parent\n * started would hand the child a DIFFERENT generation than the one its\n * parent\'s history was produced under (and a preset deleted since would fail\n * the child outright while its parent keeps running).\n *\n * Synchronous and infallible for that reason, which is what lets a child\n * creation window use it: the two in-process subagent drivers compose their\n * children inside a synchronous `setup`.\n *\n * A parent that joined no preset — a rosterless deployment — yields no join\n * and no error: there, the model-facing rows sit in the host composition and\n * the child already sees them through the global layer.\n * @param agentCtx - the joining agent\'s scope context.\n * @param parentCtx - the scope context of the agent whose composition to join.\n * @returns the preset id joined, or undefined when the parent joined none.\n * @throws when `agentCtx` carries no scope, or has already joined a preset.\n */', + jsDoc: '/**\n * Join one agent to the SAME standing composition another already runs on.\n *\n * This is how a child agent inherits its parent\'s capabilities. It is a bind,\n * not a mount: the parent\'s generation is already composed, so the child gets\n * that exact instance — the same plugin objects, the same tool registrations,\n * the same prompt sections. Re-resolving the parent\'s preset by id instead\n * would re-read the roster, and a composition file edited since the parent\n * started would hand the child a DIFFERENT generation than the one its\n * parent\'s history was produced under (and a preset deleted since would fail\n * the child outright while its parent keeps running).\n *\n * Synchronous, and with no composition failure mode of its own — it reads no\n * roster, mounts nothing, and touches no file — which is what lets a child\n * creation window use it: the two in-process subagent drivers compose their\n * children inside a synchronous `setup`. It still rejects a caller error, as\n * the `@throws` below record.\n *\n * A parent that joined no preset — a rosterless deployment — yields no join\n * and no error: there, the model-facing rows sit in the host composition and\n * the child already sees them through the global layer.\n * @param agentCtx - the joining agent\'s scope context.\n * @param parentCtx - the scope context of the agent whose composition to join.\n * @returns the preset id joined, or undefined when the parent joined none.\n * @throws when `agentCtx` carries no scope, or has already joined a preset.\n */', }, { signature: 'composedPreset(agentCtx: Context): string | undefined', diff --git a/packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml b/packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml new file mode 100644 index 0000000000..9971526c12 --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/fixtures/presets/reviewing/agent.cordis.yml @@ -0,0 +1,6 @@ +# A second agent-plane composition, so a switch is a real switch: the tool a +# joined child sees has to change with it. +- id: only + name: ../../plugins/preset-tool.js + config: + tool: reviewing_only diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts index 01c190a833..43061d46db 100644 --- a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -105,12 +105,16 @@ describe('a child agent composed in-process', () => { it('follows a parent that switched preset while blank', async () => { const { ctx, parent } = await setupPresetHost() - await ctx.agentPresets.recompose(parent.ctx, 'coding') + // A DIFFERENT preset, so the assertion below distinguishes reading the + // parent's live scope chain from reading its creation header — re-linking + // to the same id would pass either way. + await ctx.agentPresets.recompose(parent.ctx, 'reviewing') const run = await startInProcessRun(spawnRequest(parent), {}) await run.result - expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['preset_only']) + expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['reviewing_only']) + expect(run.localAgent?.session.header.agentPreset).toBe('reviewing') await run.dispose() }) }) From 20139a3fb702623583beae3cf446cdefd7cbaee2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 19:30:46 +0800 Subject: [PATCH 107/229] docs(sdk): add minimal Python example --- ...nimal-preset-owns-rl-composition.i18n.yaml | 4 +- ...8-10-minimal-preset-owns-rl-composition.md | 6 +- ...0-minimal-preset-owns-rl-composition.zh.md | 6 +- docs/user/guide/python-sdk-minimal.i18n.yaml | 6 ++ docs/user/guide/python-sdk-minimal.md | 95 +++++++++++++++++++ docs/user/guide/python-sdk-minimal.zh.md | 95 +++++++++++++++++++ docs/user/guide/quickstart.i18n.yaml | 4 +- docs/user/guide/quickstart.md | 1 + docs/user/guide/quickstart.zh.md | 1 + examples/jsonrpc-agent/README.i18n.yaml | 4 +- examples/jsonrpc-agent/README.md | 6 +- examples/jsonrpc-agent/README.zh.md | 6 +- examples/jsonrpc-agent/minimal.cordis.yml | 91 ++++++++++++++++++ examples/jsonrpc-agent/minimal.py | 42 ++++++++ ...cordis.yml => minimal.snapshot.cordis.yml} | 10 +- .../jsonrpc-agent/persistent-tools.cordis.yml | 59 ------------ examples/jsonrpc-agent/tests/sdk.snapshot.ts | 55 +++++++++-- python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 5 +- python/sdk/README.zh.md | 4 +- website/docs.ts | 10 +- 21 files changed, 421 insertions(+), 93 deletions(-) create mode 100644 docs/user/guide/python-sdk-minimal.i18n.yaml create mode 100644 docs/user/guide/python-sdk-minimal.md create mode 100644 docs/user/guide/python-sdk-minimal.zh.md create mode 100644 examples/jsonrpc-agent/minimal.cordis.yml create mode 100644 examples/jsonrpc-agent/minimal.py rename examples/jsonrpc-agent/{persistent-tools.snapshot.cordis.yml => minimal.snapshot.cordis.yml} (60%) delete mode 100644 examples/jsonrpc-agent/persistent-tools.cordis.yml diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml index 6861aff43a..68f399bab8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-minimal-preset-owns-rl-composition.md -2026-08-10-minimal-preset-owns-rl-composition.md: 043f2e45e3fe4fbb92aa6652ce099ebfde09de55 -2026-08-10-minimal-preset-owns-rl-composition.zh.md: 83f243b56b25237f19fa288f87e15eee6a264c94 +2026-08-10-minimal-preset-owns-rl-composition.md: 002cad0827e969b322997821dc978db85e2955f3 +2026-08-10-minimal-preset-owns-rl-composition.zh.md: e957b57395c68b336695bdae07ea15a54ca1ea4e diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md index 043f2e45e3..002cad0827 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md @@ -12,7 +12,7 @@ The split also hid other drift. The preset mounted one-shot Bash rather than the ## Decision -The shipped `minimal` preset is the sole RL agent composition. It declares an entry-local PTY registry and local backend, persistent `bash` with the RL environment description and 300-second timeout, `str_replace_editor`, and an entry-local compaction backend. Tool presentation remains a deployment choice. The compaction policy keeps the RL threshold, absolute retention, generation cap, and retry count; model capacity comes from routed adapter metadata because `contextWindow` is no longer a compact-basic config field. The editor accepts no `requireAbsolutePath` setting because absolute paths are its unconditional contract. +The shipped Web `minimal` preset is the sole Web owner of the RL agent composition. It declares an entry-local PTY registry and local backend, persistent `bash` with the RL environment description and 300-second timeout, `str_replace_editor`, and an entry-local compaction backend. Tool presentation remains a deployment choice. The compaction policy keeps the RL threshold, absolute retention, generation cap, and retry count; model capacity comes from routed adapter metadata because `contextWindow` is no longer a compact-basic config field. The editor accepts no `requireAbsolutePath` setting because absolute paths are its unconditional contract. The preset persona is exactly `You are a helpful software engineer assistant.` and sets `complete: true`. A complete `PromptSection` participates in ordinary assembly so tools, contexts, variables, and cooperative listeners still resolve; after the `system-prompt/assemble` waterfall, the prompt registry restores a detached copy of that section as the sole system-prompt section. Multiple effective complete sections reject assembly. This final registry constraint prevents harness identity, Web orientation, tool guidance, or an assembly listener from appending prompt text. @@ -22,6 +22,8 @@ The process-wide `core-web.cordis.yml` patch is absent. Browser UI, workspace at System-prompt and persona package tests prove final complete-section enforcement, including waterfall mutation and duplicate rejection. The shipped-preset composition test asserts the exact prompt, Bash description, absolute editor schema, and two-tool catalog under the default native presentation. The keyless Web replay sends a real request through a `minimal` agent while global identity, Web surface text, and a test section are registered, then executes two persistent Bash calls to prove environment and cwd state survive and executes the editor through an absolute path. +The standalone [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) mirrors the same prompt, tools, timeouts, and compaction policy for the bundled JSON-RPC runtime. Its keyless SDK replay asserts the assembled system prompt and two-tool catalog, executes persistent Bash across calls, and exercises the editor; the Python SDK tutorial provides the runnable entry point. + ## Alternatives considered **Keep `core-web.cordis.yml` as a compatibility patch.** Rejected because a process patch and a session preset are two independent owners for one agent contract; precedence makes either one capable of silently undoing the other. @@ -34,4 +36,4 @@ System-prompt and persona package tests prove final complete-section enforcement ## Consequences -The RL prompt is fixed rather than environment-overridable, and `minimal` is the only shipped place that states it. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The preset pays for its own PTY and compaction service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset is not a Windows agent surface. +The RL prompt is fixed rather than environment-overridable. The Web preset and standalone JSON-RPC example state the same contract for their respective launch surfaces. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The preset pays for its own PTY and compaction service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset is not a Windows agent surface. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md index 83f243b56b..e957b57395 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md @@ -12,7 +12,7 @@ Web surface 同时由两个位置定义与 Claude SWE 兼容的 RL agent(智 ## 决策 -随附的 `minimal` preset 是 RL agent 组合的唯一所有者。它声明 entry 本地的 PTY 注册表与本地后端、带 RL 环境描述且超时为 300 秒的持久 `bash`、`str_replace_editor`,以及 entry 本地的压缩后端。工具呈现仍由部署选择。压缩策略保留 RL 的阈值、绝对保留量、生成上限和重试次数;模型容量来自经路由选定的适配器元数据,因为 `contextWindow` 已不再是 compact-basic 的配置字段。编辑器不接受 `requireAbsolutePath` 设置,因为要求绝对路径是它的无条件约定。 +随附的 Web `minimal` preset 是 RL agent 组合在 Web 中的唯一所有者。它声明 entry 本地的 PTY 注册表与本地后端、带 RL 环境描述且超时为 300 秒的持久 `bash`、`str_replace_editor`,以及 entry 本地的压缩后端。工具呈现仍由部署选择。压缩策略保留 RL 的阈值、绝对保留量、生成上限和重试次数;模型容量来自经路由选定的适配器元数据,因为 `contextWindow` 已不再是 compact-basic 的配置字段。编辑器不接受 `requireAbsolutePath` 设置,因为要求绝对路径是它的无条件约定。 preset persona 恰好是 `You are a helpful software engineer assistant.`,并设置 `complete: true`。complete `PromptSection` 参与常规组装,因此工具、上下文、变量和协作式监听器仍会解析;`system-prompt/assemble` waterfall(瀑布式事件)结束后,提示词注册表会将该段落的独立副本恢复为唯一的系统提示词段落。存在多个有效 complete 段时,组装会被拒绝。这项最终注册表约束可防止 harness 身份、Web 定位、工具引导或组装监听器追加提示词文本。 @@ -22,6 +22,8 @@ preset persona 恰好是 `You are a helpful software engineer assistant.`,并 系统提示词与 persona 包测试证明了 complete 段的最终约束,包括 waterfall 修改与重复项拒绝。交付 preset 组合测试在默认原生呈现下断言精确的提示词、Bash 描述、要求绝对路径的编辑器 schema 和双工具目录。无密钥 Web 回放通过 `minimal` agent 发送一个真实请求,同时注册全局身份、Web surface 文本和一个测试段落;随后执行两次持久 Bash 调用,证明环境与 cwd 状态能够保留,并通过绝对路径执行编辑器。 +独立的 [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) 为内置 JSON-RPC 运行时复现相同的提示词、工具、超时和压缩策略。其无密钥 SDK 回放会断言组装后的系统提示词与双工具目录,跨调用执行持久 Bash,并使用编辑器;Python SDK 教程提供可运行的入口。 + ## 考虑过的替代方案 **将 `core-web.cordis.yml` 保留为兼容 patch。** 被拒绝,因为进程 patch 与会话 preset 是同一 agent 约定的两个独立所有者;优先级会使任意一方都能静默撤销另一方的配置。 @@ -34,4 +36,4 @@ preset persona 恰好是 `You are a helpful software engineer assistant.`,并 ## 后果 -RL 提示词固定不变,不能通过环境覆盖,且 `minimal` 是交付内容中唯一声明该提示词的位置。模型只看到持久 `bash` 与 `str_replace_editor`;shell 状态按 agent 隔离,并随该 agent 一并消失。preset 为自身的 PTY 与压缩服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不适用于 Windows agent surface。 +RL 提示词固定不变,不能通过环境覆盖。Web preset 与独立 JSON-RPC 示例分别在各自的启动界面声明相同的约定。模型只看到持久 `bash` 与 `str_replace_editor`;shell 状态按 agent 隔离,并随该 agent 一并消失。preset 为自身的 PTY 与压缩服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不适用于 Windows agent surface。 diff --git a/docs/user/guide/python-sdk-minimal.i18n.yaml b/docs/user/guide/python-sdk-minimal.i18n.yaml new file mode 100644 index 0000000000..3a3b7dd8a7 --- /dev/null +++ b/docs/user/guide/python-sdk-minimal.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/user/guide/python-sdk-minimal.md +python-sdk-minimal.md: 9d46278aeec625afdf30678806bc90104be00b66 +python-sdk-minimal.zh.md: ec06a205c680c1d7a83be5f949c7ff4d719defe4 diff --git a/docs/user/guide/python-sdk-minimal.md b/docs/user/guide/python-sdk-minimal.md new file mode 100644 index 0000000000..9d46278aee --- /dev/null +++ b/docs/user/guide/python-sdk-minimal.md @@ -0,0 +1,95 @@ +# Run the minimal agent with the Python SDK + +English | [中文](python-sdk-minimal.zh.md) + +This tutorial runs the minimal agent without the Web UI. The checked-in Cordis composition fixes the system prompt, tool catalog, persistent-shell behavior, and compaction policy so SDK runs use the same model-facing contract as the Web `minimal` preset. + +## Prerequisites + +- Python 3.10 or newer +- Linux x64, Linux arm64, or macOS arm64 +- A DeepSeek-compatible API endpoint and credential +- An isolated workspace that the agent may modify + +Create a virtual environment and install the SDK with its same-version bundled runtime: + +```sh +python -m venv .venv +. .venv/bin/activate +python -m pip install deepseek-harness +``` + +The runtime wheel contains the JSON-RPC executable and every plugin used by the complete [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml), so an installed SDK does not need Node.js. + +## Run the checked-in example + +Set the credential in the environment. Set `DEEPSEEK_BASE_URL` as well when the model is served by an OpenAI-compatible proxy rather than the default DeepSeek endpoint. + +```sh +export DEEPSEEK_API_KEY=sk-your-key-here +# export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 +``` + +Run one task from the repository checkout: + +```sh +python examples/jsonrpc-agent/minimal.py \ + --workspace /absolute/path/to/workspace \ + --session-root /absolute/path/to/trajectories \ + --session-id example-001 \ + "Inspect the repository and fix the failing tests." +``` + +The script prints the final assistant response. The session root receives the JSONL trajectory, including the assembled model request and every tool call. + +## Use the SDK in your own program + +The example is a thin wrapper around this SDK call: + +```python +from pathlib import Path + +from deepseek_harness import DeepSeekHarness + +config = Path("examples/jsonrpc-agent/minimal.cordis.yml").resolve() +workspace = Path("/absolute/path/to/workspace").resolve() +sessions = Path("/absolute/path/to/trajectories").resolve() + +with DeepSeekHarness( + provider="deepseek-official", + model="deepseek-v4-flash", + max_tokens=49_152, + cwd=str(workspace), + session_root=str(sessions), + cordis=str(config), +) as harness: + result = harness.run( + "Inspect the repository and fix the failing tests.", + session_id="example-001", + ) + +print(result.final_response) +``` + +`DeepSeekHarness` starts the bundled JSON-RPC runtime lazily and reuses it until the context manager exits. Reusing the same harness and session id across calls also preserves the session-owned Bash process, including its working directory, exported variables, and shell functions. + +## Contract reproduced by the configuration + +| Surface | Fixed value | +|---|---| +| System prompt | `You are a helpful software engineer assistant.` | +| Model-facing tools | Persistent `bash` and `str_replace_editor` only | +| Bash timeout | 300 seconds | +| Editor output limit | 16,000 characters | +| Compaction | Trigger ratio `0.8`, retain `20,480` tokens, summary cap `8,192` tokens, one retry | +| Session persistence | Uncompressed JSONL under `DSH_SESSION_ROOT` | + +The configuration omits harness identity, workspace prompt text, skills, one-shot Bash, task tools, and every other model-facing plugin. Filesystem policy facts are logged as runtime user context rather than appended to the system prompt. The editor requires absolute paths as an unconditional current contract, so the obsolete `requireAbsolutePath` option is absent. + +## Keep runs reproducible + +For comparable trajectories, pin the Harness commit and Python package version together, retain the exact Cordis file, and record the provider, model, endpoint, `max_tokens`, task input, workspace state, and session id for every run. Start independent runs with a clean workspace and a fresh session id; reuse a session only when multi-turn state is intentional. + +The composition uses `danger-full-access`. Run it only inside a disposable checkout or container: Bash and the editor can modify any path allowed to the runtime process. The persistent PTY backend requires a POSIX terminal substrate and is not a Windows agent surface. + +For the complete SDK lifecycle and result contract, see the [Python SDK reference](../../../python/sdk/README.md). For Cordis composition syntax, see [Configuration](./config.md). diff --git a/docs/user/guide/python-sdk-minimal.zh.md b/docs/user/guide/python-sdk-minimal.zh.md new file mode 100644 index 0000000000..ec06a205c6 --- /dev/null +++ b/docs/user/guide/python-sdk-minimal.zh.md @@ -0,0 +1,95 @@ +# 使用 Python SDK 运行极简 agent(智能体) + +[English](python-sdk-minimal.md) | 中文 + +本教程介绍如何在不使用 Web UI 的情况下运行极简 agent。仓库内置的 Cordis 组合固定了系统提示词、工具目录、持久 shell 行为和压缩(compaction)策略,因此 SDK 运行与 Web `minimal` preset 使用相同的面向模型约定。 + +## 前置要求 + +- Python 3.10 或更高版本 +- Linux x64、Linux arm64 或 macOS arm64 +- DeepSeek 兼容的 API 端点与凭据 +- agent 可以修改的隔离 workspace + +请创建虚拟环境,并安装 SDK 及其同版本内置运行时: + +```sh +python -m venv .venv +. .venv/bin/activate +python -m pip install deepseek-harness +``` + +运行时 wheel 包含 JSON-RPC 可执行文件,以及完整 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 使用的每个插件,因此安装后的 SDK 不需要 Node.js。 + +## 运行仓库内置示例 + +请在环境中设置凭据。如果模型不是由默认 DeepSeek 端点提供,而是通过 OpenAI 兼容代理提供,还需要设置 `DEEPSEEK_BASE_URL`。 + +```sh +export DEEPSEEK_API_KEY=sk-your-key-here +# export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 +``` + +从仓库 checkout 运行一个任务: + +```sh +python examples/jsonrpc-agent/minimal.py \ + --workspace /absolute/path/to/workspace \ + --session-root /absolute/path/to/trajectories \ + --session-id example-001 \ + "Inspect the repository and fix the failing tests." +``` + +脚本会打印 assistant 的最终回复。会话根目录会收到 JSONL 运行轨迹,其中包含组装后的模型请求与每次工具调用。 + +## 在自己的程序中使用 SDK + +该示例是以下 SDK 调用的轻量包装层: + +```python +from pathlib import Path + +from deepseek_harness import DeepSeekHarness + +config = Path("examples/jsonrpc-agent/minimal.cordis.yml").resolve() +workspace = Path("/absolute/path/to/workspace").resolve() +sessions = Path("/absolute/path/to/trajectories").resolve() + +with DeepSeekHarness( + provider="deepseek-official", + model="deepseek-v4-flash", + max_tokens=49_152, + cwd=str(workspace), + session_root=str(sessions), + cordis=str(config), +) as harness: + result = harness.run( + "Inspect the repository and fix the failing tests.", + session_id="example-001", + ) + +print(result.final_response) +``` + +`DeepSeekHarness` 会延迟启动内置 JSON-RPC 运行时,并持续复用,直至退出上下文管理器。在多次调用中复用同一个 harness 和 session id,还会保留该会话拥有的 Bash 进程,包括其工作目录、已导出的变量与 shell 函数。 + +## 配置复现的约定 + +| 方面 | 固定值 | +|---|---| +| 系统提示词 | `You are a helpful software engineer assistant.` | +| 面向模型的工具 | 仅持久 `bash` 与 `str_replace_editor` | +| Bash 超时 | 300 秒 | +| 编辑器输出上限 | 16,000 个字符 | +| 压缩 | 触发比例 `0.8`、保留 `20,480` 个 token、摘要上限 `8,192` 个 token、重试 1 次 | +| 会话持久化 | `DSH_SESSION_ROOT` 下未压缩的 JSONL | + +该配置省略了 harness 身份、workspace 提示词文本、skill(技能)、一次性 Bash、任务工具和其他所有面向模型的插件。文件系统策略事实记录为运行时用户上下文,而不会追加到系统提示词中。编辑器无条件要求绝对路径,因此配置中没有已经废弃的 `requireAbsolutePath` 选项。 + +## 保持运行可复现 + +为了让运行轨迹可复现且便于比较,请配套固定 Harness commit 与 Python 包版本,保留确切的 Cordis 文件,并为每次运行记录提供方、模型、端点、`max_tokens`、任务输入、workspace 状态和 session id。独立运行应使用干净的 workspace 和新的 session id;只有有意保留多轮状态时才复用会话。 + +该组合使用 `danger-full-access`。只能在可丢弃的 checkout 或容器内运行:Bash 与编辑器可以修改运行时进程有权访问的任何路径。持久 PTY 后端需要 POSIX 终端环境,因此该模式不适用于 Windows agent。 + +完整的 SDK 生命周期与结果约定见 [Python SDK 参考](../../../python/sdk/README.md)。Cordis 组合语法见[配置](./config.md)。 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index dc9b7cb25b..a52f07e5c7 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md -quickstart.md: ce196641f205324334533c025b4ac1dc791f857d -quickstart.zh.md: 3a5d6d0748ec0c7ec83c74570d0fad1e8d66a97c +quickstart.md: 13d5b2196282394619747e36ecddfa1d87f61c8d +quickstart.zh.md: 3d775db9280b43bbee2de37f7327fe8d2bd3a121 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index ce196641f2..13d5b21962 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -57,6 +57,7 @@ Open `http://127.0.0.1:3080`. The agent can read and write files, run commands, ## Next steps +- [Run the minimal agent with Python](./python-sdk-minimal.md) — use the fixed two-tool composition without the Web UI - [Configure models](./providers.md) — reach providers beyond DeepSeek, and custom gateways - [Configuration](./config.md) — understand the `cordis.yml` format - [Develop a plugin](../develop/basic/) — build your own tool or backend diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 3a5d6d0748..3d775db928 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -57,6 +57,7 @@ pnpm run dsh web ## 下一步 +- [使用 Python 运行极简 agent](./python-sdk-minimal.md) — 无需 Web UI,即可使用固定的双工具组合 - [配置模型](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 - [配置文件](./config.md) — 了解 `cordis.yml` 的格式 - [开发插件](../develop/basic/) — 编写自己的工具或后端 diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml index e1c36e959c..8da5cf7ae6 100644 --- a/examples/jsonrpc-agent/README.i18n.yaml +++ b/examples/jsonrpc-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/jsonrpc-agent/README.md -README.md: bcc1027d2edb30ab374dfa2ed13ad8e6360d923b -README.zh.md: ce255e4dd70bf8c5c6edc51afbe03bb4c66560a0 +README.md: 863b39eb9c7c65d36ceca77e945379fdd1d5fe22 +README.zh.md: a8334320896d2b16e166ef0f0ec300ab1cb0ca93 diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md index bcc1027d2e..863b39eb9c 100644 --- a/examples/jsonrpc-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -26,11 +26,11 @@ The surrounding runtime also loads JSONL session persistence and automatic conte Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js. -## Persistent tools variant +## Minimal variant -[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) is a minimal runnable variant whose model-facing surface is exactly: +[`minimal.cordis.yml`](minimal.cordis.yml) is the complete standalone counterpart of the Web `minimal` preset. It fixes the system prompt and compaction policy, and its model-facing surface is exactly: - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -It composes the local PTY, filesystem intent policy, and session sandbox policy. +It composes the local PTY, filesystem intent policy, session sandbox policy, and JSONL persistence needed by the bundled runtime. [`minimal.py`](minimal.py) runs it through the Python SDK; the [minimal Python SDK tutorial](../../docs/user/guide/python-sdk-minimal.md) covers setup, repeatable runs, and the security boundary. diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md index ce255e4dd7..a833432089 100644 --- a/examples/jsonrpc-agent/README.zh.md +++ b/examples/jsonrpc-agent/README.zh.md @@ -26,11 +26,11 @@ 通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件中指定的每个插件;目标机器无需 Node.js。 -## 持久化工具变体 +## 极简变体 -[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) 是一个最小可运行变体,面向模型的能力严格只有: +[`minimal.cordis.yml`](minimal.cordis.yml) 是 Web `minimal` preset 的完整独立版本。它固定系统提示词与压缩策略,面向模型的能力严格只有: - 所有者作用域内持久化的 `bash` - 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -它组合了本地 PTY、文件系统意图策略与会话沙箱策略。 +它组合了内置运行时所需的本地 PTY、文件系统意图策略、会话沙箱策略与 JSONL 持久化。[`minimal.py`](minimal.py) 通过 Python SDK 运行该配置;[极简 Python SDK 教程](../../docs/user/guide/python-sdk-minimal.md)介绍设置方式、可重复运行与安全边界。 diff --git a/examples/jsonrpc-agent/minimal.cordis.yml b/examples/jsonrpc-agent/minimal.cordis.yml new file mode 100644 index 0000000000..a374d1655a --- /dev/null +++ b/examples/jsonrpc-agent/minimal.cordis.yml @@ -0,0 +1,91 @@ +# Complete unattended minimal-agent composition for the Python SDK. The model +# sees one fixed system prompt and only the owner-scoped persistent Bash and +# string-replace editor tools. + +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + config: + maxTokensAsSuccess: false + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd() + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: pty + name: '@deepseek-ai/dsh-pty' + +- id: pty-local + name: '@deepseek-ai/dsh-pty-local' + config: + timeoutMs: 300000 + +# The sandbox-aware filesystem backend applies the same per-session policy as +# Bash. danger-full-access permits unrestricted workspace behavior while +# keeping one policy boundary for both tools. +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + includeHarnessIdentity: false + persona: You are a helpful software engineer assistant. + workspaceContext: false + skills: + enabled: false + toolBash: false + toolTasks: false + +- id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 + description: |- + Run commands in a bash shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * You do have access to a mirror of common linux and python packages via apt and pip. + * State is persistent across command calls and discussions with the user. + * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + +- id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' + compression: none + +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + thresholdRatio: 0.8 + retainTokens: 20480 + summarizationProvider: '' + summarizationModel: '' + maxTokens: 8192 + compactionRetries: 1 diff --git a/examples/jsonrpc-agent/minimal.py b/examples/jsonrpc-agent/minimal.py new file mode 100644 index 0000000000..c82f97c60a --- /dev/null +++ b/examples/jsonrpc-agent/minimal.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Run one minimal-agent turn through the bundled Python SDK runtime.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from deepseek_harness import DeepSeekHarness + + +CONFIG = Path(__file__).with_name("minimal.cordis.yml") + + +def main() -> None: + """Parse one task and print the agent's final response.""" + parser = argparse.ArgumentParser() + parser.add_argument("prompt", help="Task for the minimal agent") + parser.add_argument("--workspace", type=Path, default=Path.cwd()) + parser.add_argument("--session-root", type=Path, default=Path(".dsh-sessions")) + parser.add_argument("--session-id") + parser.add_argument("--provider", default="deepseek-official") + parser.add_argument("--model", default="deepseek-v4-flash") + parser.add_argument("--max-tokens", type=int) + args = parser.parse_args() + + workspace = args.workspace.resolve() + session_root = args.session_root.resolve() + with DeepSeekHarness( + provider=args.provider, + model=args.model, + max_tokens=args.max_tokens, + cwd=str(workspace), + session_root=str(session_root), + cordis=str(CONFIG.resolve()), + ) as harness: + result = harness.run(args.prompt, session_id=args.session_id) + print(result.final_response) + + +if __name__ == "__main__": + main() diff --git a/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml b/examples/jsonrpc-agent/minimal.snapshot.cordis.yml similarity index 60% rename from examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml rename to examples/jsonrpc-agent/minimal.snapshot.cordis.yml index 498d5467f2..f21d7e3654 100644 --- a/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml +++ b/examples/jsonrpc-agent/minimal.snapshot.cordis.yml @@ -1,12 +1,10 @@ -# Keyless replay keeps the persistent-tool composition intact and replaces -# only its live DeepSeek adapter with the fixture-backed provider. The catalog -# below claims the same `deepseek-official` route the agent asks for: an -# unowned route makes the SDK server mount the real adapter, which then demands -# a key this keyless lane has no way to supply. +# Keyless replay keeps the complete minimal composition intact and replaces +# only its live DeepSeek adapter with the fixture-backed provider. The replay +# catalog claims the same route initialized by the SDK. - id: base name: '@cordisjs/plugin-include' config: - path: ./persistent-tools.cordis.yml + path: ./minimal.cordis.yml patches: - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/examples/jsonrpc-agent/persistent-tools.cordis.yml b/examples/jsonrpc-agent/persistent-tools.cordis.yml deleted file mode 100644 index ebe0a00e61..0000000000 --- a/examples/jsonrpc-agent/persistent-tools.cordis.yml +++ /dev/null @@ -1,59 +0,0 @@ -# Minimal unattended composition for the persistent Bash and string-replace -# editor. It is runnable through the JSON-RPC example runtime and intentionally -# keeps the model-facing surface to exactly these two tools. - -- id: jsonrpc - name: '@deepseek-ai/dsh-jsonrpc' - -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: danger-full-access - workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd() - -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: pty - name: '@deepseek-ai/dsh-pty' - -- id: pty-local - name: '@deepseek-ai/dsh-pty-local' - -- id: fs-sandbox - name: '@deepseek-ai/dsh-fs-sandbox' - config: - cwd: !!js process.env.DSH_CWD ?? process.cwd() - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - includeHarnessIdentity: false - persona: 'You are a helpful software engineer assistant.' - workspaceContext: false - skills: - enabled: false - toolBash: false - toolTasks: false - -- id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' - -- id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' - compression: none diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index 4a29d6eb6a..a45e12b192 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -33,11 +33,21 @@ const testsDir = dirOf(import.meta.url) const snapshotsDir = join(testsDir, 'snapshots') const liveConfig = join(testsDir, '..', 'cordis.yml') const replayConfig = join(testsDir, '..', 'cordis.snapshot.yml') -const persistentToolsLiveConfig = join(testsDir, '..', 'persistent-tools.cordis.yml') -const persistentToolsReplayConfig = join(testsDir, '..', 'persistent-tools.snapshot.cordis.yml') +const minimalLiveConfig = join(testsDir, '..', 'minimal.cordis.yml') +const minimalReplayConfig = join(testsDir, '..', 'minimal.snapshot.cordis.yml') const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const MINIMAL_SYSTEM_PROMPT = 'You are a helpful software engineer assistant.' +const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell +* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. +* You don't have access to the internet via this tool. +* You do have access to a mirror of common linux and python packages via apt and pip. +* State is persistent across command calls and discussions with the user. +* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. +* Please avoid commands that may produce a very large amount of output. +* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.` + const mode = process.env.DSH_SNAPSHOT ?? 'replay' const recording = mode === 'record' const refreshing = mode === 'refresh' @@ -61,6 +71,10 @@ interface SdkScenario { expectedFiles?: Readonly<Record<string, string>> /** Assembled model-facing tool names and required argument keys. */ expectedTools?: Readonly<Record<string, readonly string[]>> + /** Exact assembled system prompt for the root request. */ + expectedSystem?: string + /** Exact model-facing descriptions for selected tools. */ + expectedToolDescriptions?: Readonly<Record<string, string>> /** Stable policy-context clauses the real assembled request must include or omit. */ policyContext?: { includes: readonly string[]; excludes: readonly string[] } } @@ -89,9 +103,11 @@ const SCENARIOS: SdkScenario[] = [ prompt: 'Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9.', sessionId: 'persistent-tools-snapshot', children: 0, - configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig }, + configs: { live: minimalLiveConfig, replay: minimalReplayConfig }, expectedFiles: { 'note.txt': 'target:\n\tnew\n' }, expectedTools: { bash: ['command'], str_replace_editor: ['command', 'path'] }, + expectedSystem: MINIMAL_SYSTEM_PROMPT, + expectedToolDescriptions: { bash: MINIMAL_BASH_DESCRIPTION }, policyContext: { includes: ['Current DSH file policy: danger-full-access.', 'file modifications by available operations'], excludes: ['write and edit tools', 'terminal sessions', 'one-shot bash commands'], @@ -125,16 +141,33 @@ async function persistedLogs(sessionsRoot: string): Promise<PersistedLog[]> { interface LoggedRequestHeader { type?: string - data?: { header?: { system?: unknown; tools?: Array<{ name: string; parameters: { required?: string[] } }> } } + data?: { header?: { system?: unknown; tools?: LoggedTool[] } } } -function assembledToolRequirements(log: PersistedLog): Record<string, string[]> { +interface LoggedTool { + readonly name: string + readonly description?: unknown + readonly parameters: { readonly required?: string[] } +} + +function assembledTools(log: PersistedLog): LoggedTool[] { const event = log.content.trimEnd().split('\n') .map(line => JSON.parse(line) as LoggedRequestHeader) .find(candidate => candidate.type === 'request/header') const tools = event?.data?.header?.tools if (tools === undefined) throw new Error('session log has no request/header tools') - return Object.fromEntries(tools.map(tool => [tool.name, tool.parameters.required ?? []])) + return tools +} + +function assembledToolRequirements(log: PersistedLog): Record<string, string[]> { + return Object.fromEntries(assembledTools(log).map(tool => [tool.name, tool.parameters.required ?? []])) +} + +function assembledToolDescriptions(log: PersistedLog): Record<string, string> { + return Object.fromEntries(assembledTools(log).map((tool) => { + if (typeof tool.description !== 'string') throw new Error(`tool ${tool.name} has no description`) + return [tool.name, tool.description] + })) } function assembledSystem(log: PersistedLog): string { @@ -400,6 +433,16 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) expect(assembledToolRequirements(parent)).toEqual(scenario.expectedTools) } + if (scenario.expectedSystem !== undefined) { + const parent = ordered[0] + if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) + expect(assembledSystem(parent)).toBe(scenario.expectedSystem) + } + if (scenario.expectedToolDescriptions !== undefined) { + const parent = ordered[0] + if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) + expect(assembledToolDescriptions(parent)).toMatchObject(scenario.expectedToolDescriptions) + } if (scenario.policyContext !== undefined) { const parent = ordered[0] if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 6467b4ca87..52e788c06d 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk/README.md -README.md: 2d545688c58a2f1b755e647d7cda9555249e41c4 -README.zh.md: b335d75aedc3a145771b23ea5d408315cae9a3e3 +README.md: f2cd6b9f1a978fe5519df3965fbe6679a72d56f5 +README.zh.md: dfa25d1d09d6edf24ebf1df3faa925493aeef7de diff --git a/python/sdk/README.md b/python/sdk/README.md index 2d545688c5..f2cd6b9f1a 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -5,8 +5,7 @@ English | [中文](README.zh.md) Python subprocess SDK for driving DeepSeek Harness over JSON-RPC stdio. The runtime inherits normal DeepSeek Harness environment variables such as `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY`, so callers can use real model -endpoints directly or point those variables at a local proxy during -benchmark runs. +endpoints directly or point those variables at a local proxy. Installing `deepseek-harness` installs the exact same-version `deepseek-harness-runtime-bin` platform wheel. The normal entry point therefore needs no executable argument: @@ -35,6 +34,8 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. +The [minimal-agent tutorial](../../docs/user/guide/python-sdk-minimal.md) provides a complete standalone Cordis file and runnable SDK example for using the two-tool minimal mode without the Web UI. + `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, events, notifications, session_root)`. The result has no prompt-level status or turn reason: `final_response` is the last committed root-session assistant text in the interval, not an output causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle. `HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `RunResult.events` contains root-session events only, so descendant messages cannot replace the root response. The low-level `session_prompt()` returns the queued `MessageId` immediately; callers that bypass `Session.run()` own any later activity boundary themselves. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index b335d75aed..dfa25d1d09 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接用真实模型端点,也可以在跑基准测试时把它们指向本地代理。 +通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接使用真实模型端点,也可以把这些变量指向本地代理。 安装 `deepseek-harness` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel 包。因此常规入口不需要传可执行文件参数: @@ -31,6 +31,8 @@ with DeepSeekHarness( `provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。`max_tokens` 是可选的正整数,用于限制根 agent(智能体)及其进程内后代每次请求的输出 token;省略时由提供方默认值控制。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 +[极简 agent 教程](../../docs/user/guide/python-sdk-minimal.md)提供完整的独立 Cordis 文件与可运行的 SDK 示例,用于在不使用 Web UI 的情况下使用双工具极简模式。 + `Session.run()` 拥有一个从提示词进入持久 inbox 时开始、到整个 agent 下一次进入空闲状态为止的活动区间,并返回 `RunResult(session_id, final_response, events, notifications, session_root)`。结果不携带提示词级状态或轮次原因:`final_response` 是该区间内根会话最后提交的助手文本,并非因果上归属于该提示词的输出。steering(中途引导)、注入的上下文和其他排队工作都可能在进入空闲状态前参与其中。 `HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`RunResult.notifications` 与 `on_notification` 会按协议传输顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期事件与会话事件。`RunResult.events` 只包含根会话事件,因此后代消息不会覆盖根会话回复。底层 `session_prompt()` 会立即返回已排队消息的 `MessageId`;绕过 `Session.run()` 的调用方必须自行负责后续的活动边界。 diff --git a/website/docs.ts b/website/docs.ts index 9fcdc7c1a7..365df21571 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -138,13 +138,21 @@ const homeAndGuide = pairedPages([ section: { root: '入门', en: 'Guide' }, order: 3, }, + { + source: 'docs/user/guide/python-sdk-minimal.md', + route: 'guide/python-sdk-minimal.md', + label: { root: 'Python SDK 极简模式', en: 'Minimal mode with Python' }, + sidebar: { root: 'zh-guide', en: 'en-guide' }, + section: { root: '入门', en: 'Guide' }, + order: 4, + }, { source: 'docs/user/guide/config.md', route: 'guide/config.md', label: { root: '配置文件', en: 'Configuration' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, - order: 4, + order: 5, }, ]) From 0a17575040b2830248d68f6bea07c56bec3517bf Mon Sep 17 00:00:00 2001 From: Huanqi Cao <caohuanqi@deepseek.com> Date: Mon, 10 Aug 2026 19:34:01 +0800 Subject: [PATCH 108/229] fix(sandbox): address review: leak FIXME, legal ACL fixture, stronger offset test, prose --- ...08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- ...026-08-08-native-windows-pull-request-ci.md | 2 +- ...-08-08-native-windows-pull-request-ci.zh.md | 2 +- .../sandbox/sandbox-windows-acl/src/index.ts | 10 +++++++--- .../tests/acl-failure-paths.spec.ts | 4 ++-- .../sandbox-windows-acl/tests/ffi.spec.ts | 18 +++++++++++++++--- .../tests/index-failure-paths.spec.ts | 7 ++++--- 7 files changed, 32 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index d6e9a87840..dcdbff1208 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 1c6a1c4dcf50ac6fc5d30ea5904fb81249e55dfe -2026-08-08-native-windows-pull-request-ci.zh.md: 4342362815ecf738a4730dc1417c1be0eaddf3af +2026-08-08-native-windows-pull-request-ci.md: 33fbf1ae378112b4fd82633a77afa52056d93d98 +2026-08-08-native-windows-pull-request-ci.zh.md: 552e5cd3129011198fe442ba747cf2fdb7d97365 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 1c6a1c4dcf..33fbf1ae37 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -18,7 +18,7 @@ Every pull request also starts an ordinary independent `windows-native` job name The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage. -The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. +The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four, three, and two concurrent instrumented workers. The selected budget therefore reduces that fan-out to one while retaining the exempt-heavy suite as a second concurrent coverage worker and preserving two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 4342362815..552e5cd312 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -18,7 +18,7 @@ Status: implemented 原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。 -16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个、3 个和 2 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 1,同时保留免覆盖率项较多的套件作为第二个并发覆盖率工作线程,并继续让两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 4878d2a183..9a166fd85d 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -249,8 +249,12 @@ export class AclSandbox { } catch (error) { // Best-effort close on the failure path (last error already captured in `error`). api.closeHandle(currentToken) - // Fail-closed cleanup: never leave a revocable (temp) grant or SID - // allocation behind a failed init. Standing workspace ACEs are NOT + // FIXME(windows-acl): a failure after createRestrictedToken leaks the restricted + // token handle and the parsed write SID — this.api stays undefined, so dispose() + // early-returns and cannot clean them up. Close the token and free the write SID + // here (the hardening-followup rework already does both). + // Fail-closed cleanup: revoke the revocable (temp) grants and free the init SID + // allocations a failed init left behind. Standing workspace ACEs are NOT // revoked — they are the intended end state (the reuse cache), not an // error artifact. const cleanupFailures: unknown[] = [] @@ -361,7 +365,7 @@ export class AclSandbox { } const token = this.token /* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always - has its token; the guard mirrors the write-SID guard's defensive shape. */ + has its token; the guard mirrors the write-SID guard. */ if (token !== undefined) { try { if (api.closeHandle(token) === 0) throwLastError(api, 'CloseHandle', 'restricted token') diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts index e6d5914bd9..005f522fda 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -72,12 +72,12 @@ function craftSid(revision: number, count: number, authority: number[] = [0, 0, function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr { const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) // AclRevision - koffi.encode(acl, 2, 'uint16', 16) // AclSize: header + one 8-byte-SID ACE + koffi.encode(acl, 2, 'uint16', 24) // AclSize: 8-byte header + one 16-byte ACE koffi.encode(acl, 4, 'uint16', 1) // AceCount const ace = 8 koffi.encode(acl, ace + 0, 'uint8', abi.ACCESS_ALLOWED_ACE_TYPE) koffi.encode(acl, ace + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT) - koffi.encode(acl, ace + 2, 'uint16', 8) + koffi.encode(acl, ace + 2, 'uint16', 16) // AceSize: header + mask + inline 8-byte SID koffi.encode(acl, ace + 4, 'uint32', abi.GRANT_MASK) const inlineSid = ace + 8 for (let offset = 0; offset < 8; offset++) { diff --git a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts index 8388911598..903f56afc3 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/ffi.spec.ts @@ -182,9 +182,21 @@ describe('sameSidAt bounded comparison', () => { expect(sameSidAt(left, 0, right, 0)).toBe(false) }) - it('accepts identical SIDs at nonzero offsets', () => { - const left = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) - const right = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) + it('accepts identical SIDs at nonzero offsets over differing leading bytes', () => { + const sid = craftSid(1, 1, [0, 0, 0, 0, 0, 5], [42]) + // Embed the same SID bytes at offset 4 of two buffers whose first four + // bytes differ: an offset-ignoring comparison reads the differing + // prefixes and must reject. + const left = allocBytes(4 + 12) + const right = allocBytes(4 + 12) + koffi.encode(left, 0, 'uint32', 0x11111111) + koffi.encode(right, 0, 'uint32', 0x22222222) + for (let offset = 0; offset < 12; offset++) { + const byte = koffi.decode(sid, offset, 'uint8') as number + koffi.encode(left, 4 + offset, 'uint8', byte) + koffi.encode(right, 4 + offset, 'uint8', byte) + } expect(sameSidAt(left, 4, right, 4)).toBe(true) + expect(sameSidAt(left, 0, right, 0)).toBe(false) // the differing prefixes are not a matching SID }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 87fc23ea9f..77e931499d 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -270,10 +270,11 @@ describe('AclSandbox init', () => { // fresh() hands out 1n to OpenProcess and 2n to OpenProcessToken; the // token-layer close of 1n succeeds and init's close of 2n fails. closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n ? 0 : 1)) + // The failure lands after this.token is stored but before this.api is + // assigned; the catch drains the SID allocations and rethrows the + // original error. (The stored restricted token and parsed write SID leak + // until process exit — see the FIXME in init's catch.) await expect(sandbox.init()).rejects.toMatchObject({ api: 'CloseHandle' }) - // The failed init never stored a restricted token: dispose skips the - // token close and the already-drained allocations. - expect(() => { sandbox.dispose() }).not.toThrow() }) it('revokes the revocable grants and aggregates cleanup failures when the token pipeline fails', async () => { From 8ebf02e0ac53f6b01fa6d938c650eaf245084f66 Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Mon, 10 Aug 2026 19:34:17 +0800 Subject: [PATCH 109/229] docs(subagent): cite issue 1723 as plain text in the approvals-pinned note A leaked host TSX_TSCONFIG_PATH redirected the tsx gate scripts to a staging checkout and masked the verify-public-repository-links rejection of the internal issue URL; all tsx-driven gates re-verified clean with the variable unset. --- .../2026-08-10-subagent-approval-pinned-never.i18n.yaml | 4 ++-- .../feature/2026-08-10-subagent-approval-pinned-never.md | 2 +- .../feature/2026-08-10-subagent-approval-pinned-never.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml index cde23b2552..322d645a70 100644 --- a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md -2026-08-10-subagent-approval-pinned-never.md: 578dbe58cd4e0a3c97552e20f27a2818ee9c9f40 -2026-08-10-subagent-approval-pinned-never.zh.md: 45bc461505b07a4c10e063a122e8003f52afd259 +2026-08-10-subagent-approval-pinned-never.md: a21c6b966b1ad00ed63e0fe87b0ce982f0daf490 +2026-08-10-subagent-approval-pinned-never.zh.md: db44ae134d34904a53691cfe78eaa5a899cf64e0 diff --git a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md index 578dbe58cd..a21c6b966b 100644 --- a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md +++ b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md @@ -6,7 +6,7 @@ English | [中文](2026-08-10-subagent-approval-pinned-never.zh.md) ## Problem -A delegated child that asked for approval had no one to ask. Under an interactive parent (`'ask'`), a background child's escalation became a pending question no product surface showed — subagent sessions are omitted from the Web sidebar, the parent's `list_agents` reports plain `running`/`idle`, and the catalog rows show only activity — so a permission-blocked child was indistinguishable from a working one; headless and unanswered compositions failed the same ask closed as `'unavailable'`. The rejection audit landed only in the child's own log, and no tool parameter or Web control can adjust a running child session's sandbox mode or approval policy ([deepseek-harness#1723](https://github.com/deepseek-harness/deepseek-harness/issues/1723)). The mechanism-heavy fix — a durable blocked-state projection, parent notices, catalog badges, and a permission write path through the subagent ownership fence — was disproportionate directly before release. +A delegated child that asked for approval had no one to ask. Under an interactive parent (`'ask'`), a background child's escalation became a pending question no product surface showed — subagent sessions are omitted from the Web sidebar, the parent's `list_agents` reports plain `running`/`idle`, and the catalog rows show only activity — so a permission-blocked child was indistinguishable from a working one; headless and unanswered compositions failed the same ask closed as `'unavailable'`. The rejection audit landed only in the child's own log, and no tool parameter or Web control can adjust a running child session's sandbox mode or approval policy (Issue #1723). The mechanism-heavy fix — a durable blocked-state projection, parent notices, catalog badges, and a permission write path through the subagent ownership fence — was disproportionate directly before release. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md index 45bc461505..db44ae134d 100644 --- a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -被委派的子 agent 发起审批请求时无人可问。在交互式父级(`'ask'`)之下,后台子 agent 的升级请求会变成一个任何产品界面都不展示的挂起问题——subagent 会话不进入 Web 侧边栏,父级的 `list_agents` 只报告普通的 `running`/`idle`,目录树的行也只显示活动状态——因此被权限拦住的子 agent 与正常干活的子 agent 无法区分;headless 与无应答者的组合则让同一次 ask 以 `'unavailable'` 失败关闭。拒绝的审计记录只落在子 agent 自己的日志里,而且没有任何工具参数或 Web 控件能调整一个正在运行的子会话的沙箱模式或审批策略([deepseek-harness#1723](https://github.com/deepseek-harness/deepseek-harness/issues/1723))。机制繁重的修复方案——持久化的受阻状态投影、父级通知、目录树徽标,以及穿过 subagent 所有权围栏的权限写入路径——在临近发布时代价不成比例。 +被委派的子 agent 发起审批请求时无人可问。在交互式父级(`'ask'`)之下,后台子 agent 的升级请求会变成一个任何产品界面都不展示的挂起问题——subagent 会话不进入 Web 侧边栏,父级的 `list_agents` 只报告普通的 `running`/`idle`,目录树的行也只显示活动状态——因此被权限拦住的子 agent 与正常干活的子 agent 无法区分;headless 与无应答者的组合则让同一次 ask 以 `'unavailable'` 失败关闭。拒绝的审计记录只落在子 agent 自己的日志里,而且没有任何工具参数或 Web 控件能调整一个正在运行的子会话的沙箱模式或审批策略(Issue #1723)。机制繁重的修复方案——持久化的受阻状态投影、父级通知、目录树徽标,以及穿过 subagent 所有权围栏的权限写入路径——在临近发布时代价不成比例。 ## 决策 From 5e3dd2fd34265946b3c780d552f4c160286f3f78 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 19:40:19 +0800 Subject: [PATCH 110/229] fix(web): clarify shipped preset capabilities --- apps/cli/config/agent-presets/code/preset.yml | 2 +- .../config/agent-presets/cordis/preset.yml | 2 +- .../config/agent-presets/minimal/preset.yml | 2 +- .../config/agent-presets/standard/preset.yml | 2 +- apps/web/tests/agent-preset-authoring.e2e.ts | 2 +- apps/web/tests/agent-preset-selection.e2e.ts | 20 +++++++++---------- .../created.expected.md | 10 +++++----- .../damaged.expected.md | 8 ++++---- .../section.expected.md | 8 ++++---- .../agent-preset-selection/header.expected.md | 2 +- .../agent-preset-selection/hero.expected.md | 4 ++-- .../agent-preset-selection/menu.expected.md | 10 +++++----- .../ui-agent-preset/src/client/locales.ts | 17 ++++++++-------- .../preset/agent-presets/README.i18n.yaml | 4 ++-- packages/preset/agent-presets/README.md | 2 +- packages/preset/agent-presets/README.zh.md | 2 +- 16 files changed, 49 insertions(+), 48 deletions(-) diff --git a/apps/cli/config/agent-presets/code/preset.yml b/apps/cli/config/agent-presets/code/preset.yml index f3426e52f4..17eaccb871 100644 --- a/apps/cli/config/agent-presets/code/preset.yml +++ b/apps/cli/config/agent-presets/code/preset.yml @@ -1,3 +1,3 @@ name: 代码模式 -description: 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 +description: 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 order: 2 diff --git a/apps/cli/config/agent-presets/cordis/preset.yml b/apps/cli/config/agent-presets/cordis/preset.yml index 49cb3c6d44..5f72051346 100644 --- a/apps/cli/config/agent-presets/cordis/preset.yml +++ b/apps/cli/config/agent-presets/cordis/preset.yml @@ -1,3 +1,3 @@ name: 创造模式 -description: 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 +description: 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;临时实验仅影响当前运行时,不会修改内置 preset。 order: 4 diff --git a/apps/cli/config/agent-presets/minimal/preset.yml b/apps/cli/config/agent-presets/minimal/preset.yml index 5521dda140..7160b51c43 100644 --- a/apps/cli/config/agent-presets/minimal/preset.yml +++ b/apps/cli/config/agent-presets/minimal/preset.yml @@ -1,3 +1,3 @@ name: 极简模式 -description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 +description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 order: 3 diff --git a/apps/cli/config/agent-presets/standard/preset.yml b/apps/cli/config/agent-presets/standard/preset.yml index 8eddfbde48..caeacd1c1e 100644 --- a/apps/cli/config/agent-presets/standard/preset.yml +++ b/apps/cli/config/agent-presets/standard/preset.yml @@ -1,3 +1,3 @@ name: 标准模式 -description: 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 +description: 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 order: 1 diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 0b82d6d808..e8ff5aa538 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -161,7 +161,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8')) const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8') expect(metadata).toContain('name: 我的模式') - expect(metadata).toContain('description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。') + expect(metadata).toContain('description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。') expect(metadata).not.toContain('order:') }, 60_000) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 2bcaa5e628..553a21bb6f 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -143,12 +143,12 @@ describe('web e2e: agent-preset selection', () => { await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE) // The chip opens on the deployment default, by the name that preset // publishes rather than its directory name. - expect(snapshot).toContain('标准模式') + expect(snapshot).toContain('Standard mode') }) it('names every preset and what it is for', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-menu')) - await page.getByRole('button', { name: '标准模式' }).click() + await page.getByRole('button', { name: 'Standard mode' }).click() const menu = page.getByRole('menu') await menu.waitFor({ timeout: 10_000 }) @@ -157,15 +157,15 @@ describe('web e2e: agent-preset selection', () => { await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) // Every shipped preset, each with the sentence saying what it composes — // the id alone never said what a preset does. - expect(snapshot).toContain('极简模式') - expect(snapshot).toContain('创造模式') + expect(snapshot).toContain('Minimal mode') + expect(snapshot).toContain('Creator mode') await page.keyboard.press('Escape') }) it('applies the staged pick to the blank session, and the host honors it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-stage')) - await page.getByRole('button', { name: '标准模式' }).click() - await page.getByRole('menuitem', { name: /极简模式/ }).click() + await page.getByRole('button', { name: 'Standard mode' }).click() + await page.getByRole('menuitem', { name: /Minimal mode/ }).click() // The chip stages; the blank session the workspace connect produced is // what the stage lands on. The host's own answer is what comes back. @@ -197,8 +197,8 @@ describe('web e2e: agent-preset selection', () => { // against its list row, so a row that never reprojected the first switch // answers "already standard" and sends nothing — and restores the catalog // instead of leaving the session reading the narrower composition. - await page.getByRole('button', { name: '极简模式' }).click() - await page.getByRole('menuitem', { name: /^标准模式/ }).first().click() + await page.getByRole('button', { name: 'Minimal mode' }).click() + await page.getByRole('menuitem', { name: /^Standard mode/ }).first().click() await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('standard') await composer.fill('/') @@ -221,10 +221,10 @@ describe('web e2e: agent-preset selection', () => { const snapshot = await captureStableAria(page, '[class*="titleRow"]', scaffold.workspaceCwd) await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE) - expect(snapshot).toContain('极简模式') + expect(snapshot).toContain('Minimal mode') // Static chrome, not a control: the header can only report a composition // the host would refuse to change. - expect(snapshot).not.toContain('button "极简模式"') + expect(snapshot).not.toContain('button "Minimal mode"') }) it('drove every surface without a page error or a stream warning', () => { diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md index e5cefe28ef..3f2b155919 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -20,7 +20,7 @@ - list: - listitem: - 'button "当前使用: 标准模式" [disabled] [pressed]': - - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 - code: standard - 'button "查看: 标准模式"': - img @@ -30,7 +30,7 @@ - text: 复制 - listitem: - 'button "设为默认: 代码模式"': - - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - text: 代码模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 - code: code - 'button "查看: 代码模式"': - img @@ -40,7 +40,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - text: 极简模式 内置 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 - code: minimal - 'button "查看: 极简模式"': - img @@ -50,7 +50,7 @@ - text: 复制 - listitem: - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - text: 创造模式 内置 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;实验不会写回当前内置 preset。 - code: cordis - 'button "查看: 创造模式"': - img @@ -62,7 +62,7 @@ - list: - listitem: - 'button "设为默认: 我的模式"': - - text: 我的模式 自定义 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - text: 我的模式 自定义 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 - code: my-agent - 'button "查看路径: 我的模式"': - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md index 8269dc2993..b4133b2459 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -20,7 +20,7 @@ - list: - listitem: - 'button "当前使用: 标准模式" [disabled] [pressed]': - - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 - code: standard - 'button "查看: 标准模式"': - img @@ -30,7 +30,7 @@ - text: 复制 - listitem: - 'button "设为默认: 代码模式"': - - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - text: 代码模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 - code: code - 'button "查看: 代码模式"': - img @@ -40,7 +40,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - text: 极简模式 内置 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 - code: minimal - 'button "查看: 极简模式"': - img @@ -50,7 +50,7 @@ - text: 复制 - listitem: - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - text: 创造模式 内置 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;实验不会写回当前内置 preset。 - code: cordis - 'button "查看: 创造模式"': - img 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 ac5d6f6736..56b8190662 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -20,7 +20,7 @@ - list: - listitem: - 'button "当前使用: 标准模式" [disabled] [pressed]': - - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 - code: standard - 'button "查看: 标准模式"': - img @@ -30,7 +30,7 @@ - text: 复制 - listitem: - 'button "设为默认: 代码模式"': - - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - text: 代码模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 - code: code - 'button "查看: 代码模式"': - img @@ -40,7 +40,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - text: 极简模式 内置 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 - code: minimal - 'button "查看: 极简模式"': - img @@ -50,7 +50,7 @@ - text: 复制 - listitem: - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - text: 创造模式 内置 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;实验不会写回当前内置 preset。 - code: cordis - 'button "查看: 创造模式"': - img diff --git a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md index ef2ad4ef57..d627a21945 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md @@ -1,4 +1,4 @@ - navigation "Session hierarchy": - button "Seeded turn" [disabled] - img -- text: 极简模式 +- text: Minimal mode diff --git a/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md b/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md index f2d54eb579..a320fa3e2f 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/hero.expected.md @@ -2,7 +2,7 @@ - img - text: workspace - img -- button "标准模式": +- button "Standard mode": - img - - text: 标准模式 + - text: Standard mode - img diff --git a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md index fd92ab8b5a..586014bd99 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md @@ -1,7 +1,7 @@ - menu: - - menuitem "标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。": - - text: 标准模式 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.": + - text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows. - img - - menuitem "代码模式 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。" - - menuitem "极简模式 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。" - - menuitem "创造模式 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。" + - menuitem "Code mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program." + - menuitem "Minimal mode Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions." + - menuitem "Creator mode Adds Cordis runtime inspection, temporary-plugin experiments, and preset-authoring guidance to Standard mode for creating new agent presets; experiments are not written back to the active built-in preset." diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 7d7453837f..a106394a61 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -35,16 +35,17 @@ export const en: Record<AgentPresetSettingsKey, string> = { setDefault: 'Set as default', view: 'View', presetStandardName: 'Standard mode', - presetStandardDescription: 'Full coding agent with file editing, shell, search, planning, delegation, and workflows.', + presetStandardDescription: + 'Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.', presetCodeName: 'Code mode', presetCodeDescription: - 'Presents Standard mode\'s tools through Code Mode: the model writes TypeScript against an SDK and runs it once instead of making multiple tool calls.', + 'All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program.', presetMinimalName: 'Minimal mode', presetMinimalDescription: - 'Exposes only bash and str_replace_editor to the model, for benchmarks and minimal reproductions.', + 'Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions.', presetCordisName: 'Creator mode', presetCordisDescription: - 'Adds self-inspection tools to Standard mode, so it can read and modify its own running composition and create new presets from it.', + 'Adds Cordis runtime inspection, temporary-plugin experiments, and preset-authoring guidance to Standard mode for creating new agent presets; experiments affect only the current runtime and do not modify the built-in preset.', duplicate: 'Duplicate', duplicateUnavailable: 'This deployment has no writable preset directory', delete: 'Delete', @@ -98,13 +99,13 @@ export const zh: Record<AgentPresetSettingsKey, string> = { setDefault: '设为默认', view: '查看', presetStandardName: '标准模式', - presetStandardDescription: '完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。', + presetStandardDescription: '功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。', presetCodeName: '代码模式', - presetCodeDescription: '标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。', + presetCodeDescription: '具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。', presetMinimalName: '极简模式', - presetMinimalDescription: '只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。', + presetMinimalDescription: '仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。', presetCordisName: '创造模式', - presetCordisDescription: '标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。', + presetCordisDescription: '在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;临时实验仅影响当前运行时,不会修改内置 preset。', duplicate: '复制', duplicateUnavailable: '此部署未配置可写的预设目录', delete: '删除', diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 65af853308..42e9961b36 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: b6d469b26a0254adc654e5cc49d3df2d10817b2d -README.zh.md: 60c7bc695c27bf2c0169a0e405aa84aa711b9b21 +README.md: 4f41361bc2f22ea87ecbd3e5699e119d8019cc16 +README.zh.md: 148bb06cfcc1c469db7b590e5facb45c35b5fb6d diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index b6d469b26a..4f41361bc2 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -63,7 +63,7 @@ A preset may publish display text in an optional `preset.yml` beside its composi ```yaml name: 极简模式 -description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 +description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 ``` It carries display text ONLY. `id` is the directory name and `trust` comes from the root the preset was discovered under, so neither is writable here — otherwise a locally authored preset could name itself into the shipped set. It is a separate file because the composition is a top-level list of plugin rows: YAML cannot carry sibling keys beside it, and a fake metadata row would hand the Loader something to load. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 60c7bc695c..148bb06cfc 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -63,7 +63,7 @@ preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本: ```yaml name: 极简模式 -description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 +description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 ``` 它**只**承载展示文本。`id` 是目录名,`trust` 取自 preset 被发现时所在的根目录,两者都不可写在这里——否则本地创作的 preset 就能把自己命名进随附集合。之所以是独立文件:组装是插件行的顶层列表,YAML 无法在其旁携带同级键,而伪造一个元信息行等于递给 Loader 一个要加载的东西。 From 13d4d4b3f3e9b1e419b046829eb3a67aa9ffbe30 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 19:44:42 +0800 Subject: [PATCH 111/229] fix(web): focus creator preset description --- apps/cli/config/agent-presets/cordis/preset.yml | 2 +- .../snapshots/agent-preset-authoring/created.expected.md | 2 +- .../snapshots/agent-preset-authoring/damaged.expected.md | 2 +- .../snapshots/agent-preset-authoring/section.expected.md | 2 +- .../tests/snapshots/agent-preset-selection/menu.expected.md | 2 +- packages/client/ui-agent-preset/src/client/locales.ts | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/cli/config/agent-presets/cordis/preset.yml b/apps/cli/config/agent-presets/cordis/preset.yml index 5f72051346..62475b6454 100644 --- a/apps/cli/config/agent-presets/cordis/preset.yml +++ b/apps/cli/config/agent-presets/cordis/preset.yml @@ -1,3 +1,3 @@ name: 创造模式 -description: 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;临时实验仅影响当前运行时,不会修改内置 preset。 +description: 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。 order: 4 diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md index 3f2b155919..395e517aa1 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -50,7 +50,7 @@ - text: 复制 - listitem: - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;实验不会写回当前内置 preset。 + - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。 - code: cordis - 'button "查看: 创造模式"': - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md index b4133b2459..b40b848b5e 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -50,7 +50,7 @@ - text: 复制 - listitem: - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;实验不会写回当前内置 preset。 + - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。 - code: cordis - 'button "查看: 创造模式"': - img 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 56b8190662..dcbe72641c 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -50,7 +50,7 @@ - text: 复制 - listitem: - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;实验不会写回当前内置 preset。 + - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。 - code: cordis - 'button "查看: 创造模式"': - img diff --git a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md index 586014bd99..78ec056f56 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md @@ -4,4 +4,4 @@ - img - menuitem "Code mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program." - menuitem "Minimal mode Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions." - - menuitem "Creator mode Adds Cordis runtime inspection, temporary-plugin experiments, and preset-authoring guidance to Standard mode for creating new agent presets; experiments are not written back to the active built-in preset." + - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance." diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index a106394a61..0fab8db94b 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -45,7 +45,7 @@ export const en: Record<AgentPresetSettingsKey, string> = { 'Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions.', presetCordisName: 'Creator mode', presetCordisDescription: - 'Adds Cordis runtime inspection, temporary-plugin experiments, and preset-authoring guidance to Standard mode for creating new agent presets; experiments affect only the current runtime and do not modify the built-in preset.', + 'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.', duplicate: 'Duplicate', duplicateUnavailable: 'This deployment has no writable preset directory', delete: 'Delete', @@ -105,7 +105,7 @@ export const zh: Record<AgentPresetSettingsKey, string> = { presetMinimalName: '极简模式', presetMinimalDescription: '仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。', presetCordisName: '创造模式', - presetCordisDescription: '在标准模式上增加 Cordis 运行时检查、临时插件实验和 preset 创作指导,用于创建新的 Agent preset;临时实验仅影响当前运行时,不会修改内置 preset。', + presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。', duplicate: '复制', duplicateUnavailable: '此部署未配置可写的预设目录', delete: '删除', From 299cafad0106396dc5053065371657555652e45d Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 19:45:54 +0800 Subject: [PATCH 112/229] fix(python): rename SDK distribution --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 4 +- ...-executable-sdk-runtime-distribution.zh.md | 4 +- .../workflows/build-exe-for-python-sdk.yml | 12 +++--- .gitlab-ci.yml | 6 +-- THIRD_PARTY_NOTICES.md | 2 +- docs/user/guide/python-sdk-minimal.i18n.yaml | 4 +- docs/user/guide/python-sdk-minimal.md | 39 ++++++++++++++++++- docs/user/guide/python-sdk-minimal.zh.md | 39 ++++++++++++++++++- python/README.i18n.yaml | 4 +- python/README.md | 2 +- python/README.zh.md | 2 +- python/development.i18n.yaml | 4 +- python/development.md | 2 +- python/development.zh.md | 2 +- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 8 +++- python/sdk/README.zh.md | 8 +++- python/sdk/pyproject.toml | 2 +- python/sdk/tests/test_release_version.py | 12 ++++++ python/sdk/uv.lock | 14 +++---- scripts/build-python-release.py | 11 +++++- scripts/gen-third-party-notices.spec.ts | 2 +- scripts/gen-third-party-notices.ts | 2 +- 27 files changed, 151 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 8bef07d042..c1ab35fa0b 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: a45678c9bb5fcae340ff7134687890879f56c630 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: f1fccc508471356dd6434da0e126ed38f15ed3ba +2026-07-10-single-file-executable-sdk-runtime-distribution.md: fd232e8893b7beebe2e279cb5532daf8ef73a8a3 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: bb0b6f8f660a42495da651a581236a7ce2a50773 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index a45678c9bb..fd232e8893 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -48,13 +48,13 @@ CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workf The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with `deepseek-harness-sdk` depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-jsonrpc` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. ### Naming lineage -`@deepseek-ai/dsh-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg-<platform>-<arch>` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python dist names are `deepseek-harness` / `deepseek-harness-runtime-bin`. +`@deepseek-ai/dsh-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg-<platform>-<arch>` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules remain `deepseek_harness` / `deepseek_harness_runtime`. ## Disposition of worker-style plugins diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index f1fccc5084..bb0b6f8f66 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -48,13 +48,13 @@ CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 `deepseek-harness-sdk` 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-jsonrpc` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 ### 命名血统 -`@deepseek-ai/dsh-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包 manifest;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg-<platform>-<arch>`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。 +`@deepseek-ai/dsh-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包 manifest;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg-<platform>-<arch>`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发包名为 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名仍为 `deepseek_harness` / `deepseek_harness_runtime`。 ## 工作线程插件 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 017b77ee75..0924da5b35 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -92,7 +92,7 @@ jobs: sdk-wheel: needs: plan - name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -113,8 +113,8 @@ jobs: - uses: actions/upload-artifact@v7 with: - name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl - path: dist-python/deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl + path: dist-python/deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl if-no-files-found: error build: @@ -197,7 +197,7 @@ jobs: - uses: actions/download-artifact@v8 with: - name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl path: dist-python - name: Install only the SDK into a clean venv and run zero-config @@ -208,7 +208,7 @@ jobs: python -m venv "$RUNNER_TEMP/dsh-sdk-smoke" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \ --find-links dist-python \ - deepseek-harness=="$VERSION" + deepseek-harness-sdk=="$VERSION" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ --scenario sdk-default @@ -238,7 +238,7 @@ jobs: esac docker run --rm -e VERSION -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk - /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness=="$VERSION" + /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness-sdk=="$VERSION" /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default ' diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b4af9e3ecb..fd56278195 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -42,7 +42,7 @@ sdk-wheel: - uv run --python 3.10 --group test --project python/sdk python scripts/smoke-python-runtime.py --scenario all --exe "$EXE" - python scripts/build-python-release.py --package runtime --tag "$CI_COMMIT_TAG" --platform "$PLATFORM" --runtime-exe "$EXE" --output-dir "release/$PLATFORM" - python -m venv .wheel-smoke - - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness=="$DSH_VERSION" + - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness-sdk=="$DSH_VERSION" - .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-default - | if [ "${PLATFORM#linux-}" != "$PLATFORM" ]; then @@ -55,7 +55,7 @@ sdk-wheel: linux-arm64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; *) echo "Unsupported Linux platform $PLATFORM"; exit 1 ;; esac - docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness==$DSH_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" + docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" fi artifacts: paths: [release/$PLATFORM/*.whl] @@ -112,7 +112,7 @@ publish-python: - python -m pip install twine==6.2.0 script: - test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 4 - - test -f "release/sdk/deepseek_harness-${DSH_VERSION}-py3-none-any.whl" + - test -f "release/sdk/deepseek_harness_sdk-${DSH_VERSION}-py3-none-any.whl" - test -f "release/linux-x64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_x86_64.whl" - test -f "release/linux-arm64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_aarch64.whl" - test -f "release/macos-arm64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-macosx_11_0_arm64.whl" diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fec5de6128..d094c5938e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -181,7 +181,7 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm | Package | License | Role | | --- | --- | --- | | [`hatchling`](https://github.com/pypa/hatch) | MIT | build backend | -| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness` | +| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness-sdk` | | [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only | | [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool | diff --git a/docs/user/guide/python-sdk-minimal.i18n.yaml b/docs/user/guide/python-sdk-minimal.i18n.yaml index 3a3b7dd8a7..975a035c74 100644 --- a/docs/user/guide/python-sdk-minimal.i18n.yaml +++ b/docs/user/guide/python-sdk-minimal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/python-sdk-minimal.md -python-sdk-minimal.md: 9d46278aeec625afdf30678806bc90104be00b66 -python-sdk-minimal.zh.md: ec06a205c680c1d7a83be5f949c7ff4d719defe4 +python-sdk-minimal.md: e658fadae9575fd8bbc2aab2c622df7079c7addf +python-sdk-minimal.zh.md: ef37e1e801512bf60f212e69b61eb423ce4ab1d1 diff --git a/docs/user/guide/python-sdk-minimal.md b/docs/user/guide/python-sdk-minimal.md index 9d46278aee..e658fadae9 100644 --- a/docs/user/guide/python-sdk-minimal.md +++ b/docs/user/guide/python-sdk-minimal.md @@ -11,15 +11,50 @@ This tutorial runs the minimal agent without the Web UI. The checked-in Cordis c - A DeepSeek-compatible API endpoint and credential - An isolated workspace that the agent may modify +## Install the SDK + +Choose either the public package or a source build. Both install the `deepseek-harness-sdk` distribution and expose the `deepseek_harness` Python module. + +### Install from PyPI + Create a virtual environment and install the SDK with its same-version bundled runtime: ```sh python -m venv .venv . .venv/bin/activate -python -m pip install deepseek-harness +python -m pip install deepseek-harness-sdk ``` -The runtime wheel contains the JSON-RPC executable and every plugin used by the complete [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml), so an installed SDK does not need Node.js. +### Build from source + +A source build additionally requires Git, Node.js ^22.19 or >= 24, Corepack-enabled pnpm 11, and `uv`. The following commands build the runtime for the current supported host platform, build both wheels, and install them into the active virtual environment: + +```sh +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +cd deepseek-harness +python -m pip install uv==0.11.23 +corepack enable +pnpm install + +case "$(uname -s):$(uname -m)" in + Linux:x86_64) runtime_platform=linux-x64 ;; + Linux:aarch64|Linux:arm64) runtime_platform=linux-arm64 ;; + Darwin:arm64) runtime_platform=macos-arm64 ;; + *) echo "unsupported platform" >&2; exit 1 ;; +esac + +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="node24-$runtime_platform" +version="$(node -p "require('./package.json').version")" +python scripts/build-python-release.py --package sdk --output-dir dist-python +python scripts/build-python-release.py \ + --package runtime \ + --platform "$runtime_platform" \ + --runtime-exe "dist-exe/dsh-jsonrpc-agent-pkg-$runtime_platform" \ + --output-dir dist-python +python -m pip install --find-links dist-python "deepseek-harness-sdk==$version" +``` + +The runtime wheel contains the JSON-RPC executable and every plugin used by the complete [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml), so neither installation path needs Node.js after installation. ## Run the checked-in example diff --git a/docs/user/guide/python-sdk-minimal.zh.md b/docs/user/guide/python-sdk-minimal.zh.md index ec06a205c6..ef37e1e801 100644 --- a/docs/user/guide/python-sdk-minimal.zh.md +++ b/docs/user/guide/python-sdk-minimal.zh.md @@ -11,15 +11,50 @@ - DeepSeek 兼容的 API 端点与凭据 - agent 可以修改的隔离 workspace +## 安装 SDK + +可以选择安装公开包或从源码构建。两种方式都会安装 `deepseek-harness-sdk` 分发包,并提供 `deepseek_harness` Python 模块。 + +### 从 PyPI 安装 + 请创建虚拟环境,并安装 SDK 及其同版本内置运行时: ```sh python -m venv .venv . .venv/bin/activate -python -m pip install deepseek-harness +python -m pip install deepseek-harness-sdk ``` -运行时 wheel 包含 JSON-RPC 可执行文件,以及完整 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 使用的每个插件,因此安装后的 SDK 不需要 Node.js。 +### 从源码构建 + +从源码构建还需要 Git、Node.js ^22.19 或 >= 24、通过 Corepack 启用的 pnpm 11,以及 `uv`。以下命令为当前受支持的宿主平台构建运行时和两个 wheel 包,并将它们安装进当前虚拟环境: + +```sh +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +cd deepseek-harness +python -m pip install uv==0.11.23 +corepack enable +pnpm install + +case "$(uname -s):$(uname -m)" in + Linux:x86_64) runtime_platform=linux-x64 ;; + Linux:aarch64|Linux:arm64) runtime_platform=linux-arm64 ;; + Darwin:arm64) runtime_platform=macos-arm64 ;; + *) echo "unsupported platform" >&2; exit 1 ;; +esac + +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="node24-$runtime_platform" +version="$(node -p "require('./package.json').version")" +python scripts/build-python-release.py --package sdk --output-dir dist-python +python scripts/build-python-release.py \ + --package runtime \ + --platform "$runtime_platform" \ + --runtime-exe "dist-exe/dsh-jsonrpc-agent-pkg-$runtime_platform" \ + --output-dir dist-python +python -m pip install --find-links dist-python "deepseek-harness-sdk==$version" +``` + +运行时 wheel 包含 JSON-RPC 可执行文件,以及完整 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 使用的每个插件,因此两种安装方式完成后都不再需要 Node.js。 ## 运行仓库内置示例 diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 0086d8519f..ab8ad1f4f8 100644 --- a/python/README.i18n.yaml +++ b/python/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/README.md -README.md: 6ab9de681471c4be3bff72ddbf6ce8f118224d4d -README.zh.md: 82fca597791f19caa21a7a433e533a4d47c64ccb +README.md: 75276a915eb4b63f84e0876de46e6d8d63540b59 +README.zh.md: 7791231f9899bd1cca0d62ad35e388db608294c2 diff --git a/python/README.md b/python/README.md index 6ab9de6814..75276a915e 100644 --- a/python/README.md +++ b/python/README.md @@ -8,7 +8,7 @@ Python packages for driving DeepSeek Harness as a subprocess. The client SDK com | Directory | Dist / module | Role | |---|---|---| -| [sdk](sdk/README.md) | `deepseek-harness` / `deepseek_harness` | High-level turns API and lower-level JSON-RPC client | +| [sdk](sdk/README.md) | `deepseek-harness-sdk` / `deepseek_harness` | High-level turns API and lower-level JSON-RPC client | | [sdk-runtime](sdk-runtime/README.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | Bundled runtime binaries and default agent configuration | ## Behavior diff --git a/python/README.zh.md b/python/README.zh.md index 82fca59779..7791231f98 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -8,7 +8,7 @@ | 目录 | 分发名 / 模块 | 职责 | |---|---|---| -| [sdk](sdk/README.md) | `deepseek-harness` / `deepseek_harness` | 高层轮次 API 与低层 JSON-RPC 客户端 | +| [sdk](sdk/README.md) | `deepseek-harness-sdk` / `deepseek_harness` | 高层轮次 API 与低层 JSON-RPC 客户端 | | [sdk-runtime](sdk-runtime/README.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 内置运行时二进制与默认 agent(智能体)配置 | ## 行为 diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index 1a7b57f86d..c341c32cea 100644 --- a/python/development.i18n.yaml +++ b/python/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/development.md -development.md: b0d4875f0d5b7c8fd2b4b480ac67793741640710 -development.zh.md: 053cd1d022ef5fc7d8cff98b9fc3234df6ff4cd1 +development.md: 9614c06436ab6863a5e1b2ff83fbe605552dc13b +development.zh.md: 1c646ca39735b85a5d380768fe215c92532be7e7 diff --git a/python/development.md b/python/development.md index b0d4875f0d..9614c06436 100644 --- a/python/development.md +++ b/python/development.md @@ -55,7 +55,7 @@ Build the pure SDK wheel once and one runtime wheel on each native platform: version="$(node -p "require('./package.json').version")" python scripts/build-python-release.py --package sdk --output-dir dist-python python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness=="$version" +pip install --find-links dist-python deepseek-harness-sdk=="$version" ``` The runtime distribution is wheel-only. The release pipeline publishes three platform wheels with the pure SDK wheel: Linux x64, Linux arm64, and macOS arm64. A `python-vX.Y.Z` tag is accepted only when it matches the repository version. diff --git a/python/development.zh.md b/python/development.zh.md index 053cd1d022..1c646ca397 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -55,7 +55,7 @@ with DeepSeekHarness() as harness: version="$(node -p "require('./package.json').version")" python scripts/build-python-release.py --package sdk --output-dir dist-python python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness=="$version" +pip install --find-links dist-python deepseek-harness-sdk=="$version" ``` 运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布三个平台 wheel 包:Linux x64、Linux arm64 和 macOS arm64。只有与仓库版本匹配时,才接受 `python-vX.Y.Z` 标签。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index bc52b5ee6c..d06131b7e7 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md -README.md: 07bb3c574b3cd49f1dc74f0e9d9bd1bb7ca9b216 -README.zh.md: 0613b6faf68ea3bdb6c9b673677fc483b79dab72 +README.md: 5c7c6f66083a1b56cc6b4aed9565e8b1be014ccc +README.zh.md: cef1478710d20d7faa612e50d0c2f8ec19e8716a diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 07bb3c574b..5c7c6f6608 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness` client spawns, and ships the default configuration behind zero-config runs. +Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness-sdk` client spawns, and ships the default configuration behind zero-config runs. ## Runtime carriers diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 0613b6faf6..cef1478710 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 +Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness-sdk` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 ## 运行时载体 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 52e788c06d..08da23d879 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk/README.md -README.md: f2cd6b9f1a978fe5519df3965fbe6679a72d56f5 -README.zh.md: dfa25d1d09d6edf24ebf1df3faa925493aeef7de +README.md: 2350fbfd5dd0094bfd7a11f81d8226e960879923 +README.zh.md: 5120a8c5f65360485d450614186d5cb2702fda17 diff --git a/python/sdk/README.md b/python/sdk/README.md index f2cd6b9f1a..2350fbfd5d 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -7,7 +7,13 @@ runtime inherits normal DeepSeek Harness environment variables such as `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY`, so callers can use real model endpoints directly or point those variables at a local proxy. -Installing `deepseek-harness` installs the exact same-version `deepseek-harness-runtime-bin` platform wheel. The normal entry point therefore needs no executable argument: +Install the `deepseek-harness-sdk` distribution from PyPI; the import module remains `deepseek_harness`: + +```sh +python -m pip install deepseek-harness-sdk +``` + +Installing `deepseek-harness-sdk` installs the exact same-version `deepseek-harness-runtime-bin` platform wheel. The normal entry point therefore needs no executable argument: ```py from deepseek_harness import DeepSeekHarness diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index dfa25d1d09..5120a8c5f6 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -4,7 +4,13 @@ 通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接使用真实模型端点,也可以把这些变量指向本地代理。 -安装 `deepseek-harness` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel 包。因此常规入口不需要传可执行文件参数: +请从 PyPI 安装 `deepseek-harness-sdk` 分发包;导入模块仍为 `deepseek_harness`: + +```sh +python -m pip install deepseek-harness-sdk +``` + +安装 `deepseek-harness-sdk` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel 包。因此常规入口不需要传可执行文件参数: ```py from deepseek_harness import DeepSeekHarness diff --git a/python/sdk/pyproject.toml b/python/sdk/pyproject.toml index eeef355e90..48ffbf2499 100644 --- a/python/sdk/pyproject.toml +++ b/python/sdk/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling>=1.30.1"] build-backend = "hatchling.build" [project] -name = "deepseek-harness" +name = "deepseek-harness-sdk" version = "0.0.0.dev0" description = "Python SDK for DeepSeek Harness" readme = "README.md" diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 7e5f660070..cf5a6cf57b 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -39,6 +39,18 @@ def test_repository_version_rejects_non_stable_versions(tmp_path: Path) -> None: build_python_release.repository_version(tmp_path) +def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: Path) -> None: + destination = tmp_path / "staging" + + build_python_release.stage_sdk(destination, "1.2.3") + + pyproject = (destination / "pyproject.toml").read_text() + assert 'name = "deepseek-harness-sdk"' in pyproject + assert 'version = "1.2.3"' in pyproject + assert '"deepseek-harness-runtime-bin==1.2.3"' in pyproject + assert (destination / "src" / "deepseek_harness" / "__init__.py").is_file() + + @pytest.mark.parametrize(("target", "with_helper"), [("linux-x64", False), ("macos-arm64", True)]) def test_stage_runtime_copies_platform_payload( tmp_path: Path, target: str, with_helper: bool diff --git a/python/sdk/uv.lock b/python/sdk/uv.lock index 94219b95ad..e2a62a9fe0 100644 --- a/python/sdk/uv.lock +++ b/python/sdk/uv.lock @@ -21,7 +21,12 @@ wheels = [ ] [[package]] -name = "deepseek-harness" +name = "deepseek-harness-runtime-bin" +version = "0.0.0.dev0" +source = { editable = "../sdk-runtime" } + +[[package]] +name = "deepseek-harness-sdk" version = "0.0.0.dev0" source = { editable = "." } dependencies = [ @@ -43,17 +48,12 @@ requires-dist = [ [package.metadata.requires-dev] test = [{ name = "pytest", specifier = ">=8.0" }] -[[package]] -name = "deepseek-harness-runtime-bin" -version = "0.0.0.dev0" -source = { editable = "../sdk-runtime" } - [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index ec049cdd4f..c9ee5e31c0 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -17,6 +17,8 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +SDK_DISTRIBUTION = "deepseek-harness-sdk" +RUNTIME_DISTRIBUTION = "deepseek-harness-runtime-bin" PLATFORMS = { "linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"), "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), @@ -53,7 +55,7 @@ def main() -> None: if args.package == "sdk": stage_sdk(staging, version) environment = None - expected = output_dir / f"deepseek_harness-{version}-py3-none-any.whl" + expected = output_dir / f"deepseek_harness_sdk-{version}-py3-none-any.whl" else: platform_tag, executable_name = PLATFORMS[args.platform] stage_runtime(staging, version, args.runtime_exe.resolve(), executable_name) @@ -160,6 +162,11 @@ def verify_wheel( raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}") if metadata.get("Version") != version: raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}") + expected_distribution = SDK_DISTRIBUTION if package == "sdk" else RUNTIME_DISTRIBUTION + if metadata.get("Name") != expected_distribution: + raise RuntimeError( + f"{wheel} has distribution name {metadata.get('Name')}, expected {expected_distribution}" + ) runtime_files = [ name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name ] @@ -177,7 +184,7 @@ def verify_wheel( raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": requirements = metadata.get_all("Requires-Dist") or [] - expected_requirement = f"deepseek-harness-runtime-bin=={version}" + expected_requirement = f"{RUNTIME_DISTRIBUTION}=={version}" if expected_requirement not in requirements: raise RuntimeError(f"{wheel} does not pin {expected_requirement}; found {requirements}") diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index aa3198057b..5801f1b2f3 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -227,7 +227,7 @@ describe('collectPythonDependencies', () => { it('excludes normalized local project names without exempting a third-party prefix', () => { const pyprojects = [ '[project]\nname = "deepseek-harness-runtime-bin"\ndependencies = ["pydantic"]\n', - '[project]\nname = "deepseek-harness"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n', + '[project]\nname = "deepseek-harness-sdk"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n', ] expect(() => collectPythonDependencies(pyprojects)).toThrow( 'python dependency deepseek-unrelated is missing from PYTHON_METADATA', diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index c2ab21688a..23d41313d8 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -83,7 +83,7 @@ const OVERRIDES: Record<string, { license?: string; repo?: string }> = { * the generator fails when a manifest names a package this map misses. */ const PYTHON_METADATA: Record<string, { license: string; repo: string; role: string }> = { - pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness`' }, + pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness-sdk`' }, hatchling: { license: 'MIT', repo: 'https://github.com/pypa/hatch', role: 'build backend' }, pytest: { license: 'MIT', repo: 'https://github.com/pytest-dev/pytest', role: 'test-only' }, } From 5ba4055ed44f74d5fc4cb4f414f2f7edfdd6adc0 Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Mon, 10 Aug 2026 19:46:07 +0800 Subject: [PATCH 113/229] docs(subagent): trim comments in the delegated-policy additions --- .../tests/inheritance.spec.ts | 10 ++---- .../tests/structured.spec.ts | 3 +- packages/subagent/subagent/src/child-agent.ts | 34 +++++++------------ .../tests/continuation-inheritance.spec.ts | 6 ++-- .../subagent/tests/continuation.spec.ts | 2 +- .../tests/tool-subagent-report.spec.ts | 2 +- 6 files changed, 20 insertions(+), 37 deletions(-) diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts index 17620f7774..899772b964 100644 --- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -84,8 +84,7 @@ describe('in-process policy inheritance', () => { const { ctx, parent } = await setupWalled(script) const blocked = join(workspace, 'spawn-blocked.txt') setSandboxMode(parent.session, 'read-only') - // The parent keeps the interactive deployment default: the child pin must - // not depend on any parent approval override. + // No parent approval override: the child pin must not depend on one. expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() const parentLogLength = parent.session.events.length script.push( @@ -125,8 +124,7 @@ describe('in-process policy inheritance', () => { .join('\n') expect(contextText).toContain('Current DSH file policy: read-only') expect(contextText).toContain('Approval prompts are disabled') - // The delegation-scope statement is a runtime-context fact, so the - // deployment system prompt stays uniform across parents and children. + // The statement rides runtime context; the system prompt stays uniform. expect(contextText).toContain('You are a delegated subagent') expect(request.data.header.system).not.toContain('Approval prompts are disabled') expect(request.data.header.system).not.toContain('You are a delegated subagent') @@ -215,8 +213,7 @@ describe('in-process policy inheritance', () => { it('rejects a child escalation deterministically even when an answerer would allow it', async () => { const script: Script = [] const { ctx, parent } = await setupWalled(script) - // A root answerer that would GRANT: the pinned 'never' must resolve - // before any answerer is consulted, so this never runs for the child. + // A granting answerer proves the pin resolves before any answerer runs. let consulted = false ctx.on('approval/request', () => { consulted = true @@ -243,7 +240,6 @@ describe('in-process policy inheritance', () => { expect(consulted).toBe(false) expect(toolResultTexts(child).join('\n')) .toContain('the user rejected escalating this operation to "workspace-write"') - // The deterministic rejection still leaves the full audit pair on the child log. const asked = child.session.events.find( (event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked', ) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 36e63283da..eb9815b56b 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -247,8 +247,7 @@ describe('in-process structured output', () => { const result = await run.result expect(result.stopReason).toBe('error') expect(result.structured).toBeUndefined() - // Exactly one model request and one caller-supplied user message (the - // delegation runtime-context snapshot aside): no nudge turn exists. + // Exactly one model request and one caller-supplied user message: no nudge turn exists. expect(adapter.requests.length).toBe(1) const child = ctx.agents.get(run.id)! expect(child.session.events.filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1) diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index bc0cf949b9..d1728ad74a 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -113,13 +113,9 @@ export interface ChildComposition { } /** - * Model-facing statement every in-process child receives: the permission - * scope is fixed at delegation and approval prompts are unavailable, so the - * child reports a scope limitation instead of retrying denied operations. - * A runtime-context contribution (not a system-prompt section) because it is - * a per-session fact: the deployment's system prompt stays uniform across - * parents and children, and the statement joins the same durable snapshot - * that carries the sandbox-policy and approval-policy sentences. + * Model-facing delegation-scope statement for every in-process child. A + * runtime-context contribution rather than a system-prompt section, so the + * deployment's system prompt stays uniform across parents and children. */ export const SUBAGENT_DELEGATION_CONTEXT = 'You are a delegated subagent: your permission scope was fixed when you were started and cannot be ' @@ -131,14 +127,12 @@ export const SUBAGENT_DELEGATION_CONTEXT * Apply one child's scoped composition inside its creation window: the fixed * delegation-scope statement, a shadowing persona section, and a tool * restriction, all owned by the child's scope and therefore invisible to its - * parent and siblings. Both creation and cold resume pass through here, so a - * resumed child keeps the same statement. + * parent and siblings. Creation and cold resume both pass through here. * @param childCtx - the child agent's scoped creation context. * @param composition - the persona and tool filter to install. */ export function applyChildComposition(childCtx: Context, composition: ChildComposition): void { - // After sandbox:policy (110) and approval:policy (115): scope, then policy, - // then what a delegated child does about a denial. + // Order 120: after the sandbox:policy (110) and approval:policy (115) sentences. childCtx.systemPrompt.context({ name: 'subagent:delegation', order: 120, text: SUBAGENT_DELEGATION_CONTEXT }) if (composition.persona !== undefined) { childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona }) @@ -151,11 +145,9 @@ export interface DelegatedPolicyOverrides { /** The parent session's explicit sandbox-mode override, or `undefined` without one. */ readonly sandboxMode: SandboxMode | undefined /** - * The child's pinned approval policy, or `undefined` when no approval - * capability is composed. Always `'never'` with one composed: a delegated - * child acts only within the sandbox scope fixed at delegation, so the - * composed `ApprovalService` rejects every child ask deterministically - * instead of waiting on a prompt no one is watching. + * `'never'` whenever the approval capability is composed, `undefined` + * otherwise: a delegated child acts only within the sandbox scope fixed at + * delegation, so its asks are rejected deterministically. */ readonly approvalPolicy: 'never' | undefined } @@ -163,12 +155,10 @@ export interface DelegatedPolicyOverrides { /** * Capture the policy to seed into one delegation. Call synchronously before * the child start's first await: a later parent switch belongs to the - * parent's future, not to this child. The sandbox scope is the parent - * session's explicit override — deployment defaults and one-shot grants are - * never captured, so an unswitched parent leaves the child following the - * deployment default dynamically. The approval policy is never inherited: it - * is pinned to `'never'` whenever the approval capability is composed, - * regardless of the parent's own policy. + * parent's future, not to this child. Only the parent session's explicit + * sandbox override is captured — never deployment defaults or one-shot + * grants — and the approval policy is pinned to `'never'` regardless of the + * parent's own policy. * @param parent - the delegating parent agent. * @returns the sandbox override (or `undefined` without one) and the approval pin. */ diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index 1e539c28c2..2bc63888ae 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -74,8 +74,7 @@ describe('continuable policy inheritance', () => { it('seeds the parent sandbox override and pins approval to never', async () => { const { ctx, parent } = await setup([textResponse('child done')]) setSandboxMode(parent.session, 'danger-full-access') - // The parent keeps the interactive deployment default: the child pin must - // not depend on any parent approval override. + // No parent approval override: the child pin must not depend on one. expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() let child: Agent | undefined ctx.on('agent/created', ({ agent }) => { @@ -95,11 +94,10 @@ describe('continuable policy inheritance', () => { { type: 'sandbox/mode', data: { mode: 'danger-full-access', source: 'delegation' } }, { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, ]) - // Durable: a reload folds the same effective policy; the parent keeps its own. + // Durable: a reload folds the same effective policy. expect(effectiveSandboxMode(loaded.events)).toBe('danger-full-access') expect(effectiveApprovalPolicy(loaded.events)).toBe('never') expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() - // The child's runtime-context snapshot states the fixed delegation scope. const runtimeContext = loaded.events.find( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin' diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 6e23f6ccae..da3c474aec 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -103,7 +103,7 @@ function hasUserText(events: readonly SessionEvent[], text: string): boolean { && event.data.content.some(block => block.type === 'text' && block.text === text)) } -/** Every caller-supplied user-role message text in log order, for FIFO assertions (framework runtime-context snapshots excluded). */ +/** Caller-supplied user message texts in log order (runtime-context snapshots excluded). */ function userTexts(events: readonly SessionEvent[]): string[] { return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index c74fb94646..47d0fb3269 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -411,7 +411,7 @@ describe('dsh-tool-subagent-report', () => { }) }) -/** Prove report delivery uses ordinary logged user messages (framework runtime-context snapshots excluded). */ +/** Prove report delivery uses ordinary logged user messages (runtime-context snapshots excluded). */ function userTexts(events: readonly SessionEvent[]): string[] { return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) From bf39cef48fd225c336e6026026ab6b8f0a227d55 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:52:07 -0700 Subject: [PATCH 114/229] fix(ci): tolerate platform-specific coverage and curl retries --- packages/preset/agent-presets/src/authoring.ts | 2 ++ scripts/prepare-ci-bubblewrap.sh | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/preset/agent-presets/src/authoring.ts b/packages/preset/agent-presets/src/authoring.ts index 5ac4e55874..8a40879a24 100644 --- a/packages/preset/agent-presets/src/authoring.ts +++ b/packages/preset/agent-presets/src/authoring.ts @@ -105,6 +105,8 @@ async function tightenModes(dir: string): Promise<void> { if (entry.isDirectory()) { await tightenModes(target) } else { + /* v8 ignore next -- Windows mode bits cannot represent POSIX owner-execute state; + * the Windows native gate preserves the DACL while the POSIX suite covers this branch. */ await chmod(target, ((await stat(target)).mode & 0o100) === 0 ? 0o600 : 0o700) } } diff --git a/scripts/prepare-ci-bubblewrap.sh b/scripts/prepare-ci-bubblewrap.sh index 00a513db8f..e5f0902750 100755 --- a/scripts/prepare-ci-bubblewrap.sh +++ b/scripts/prepare-ci-bubblewrap.sh @@ -19,7 +19,7 @@ fi archive="${RUNNER_TEMP}/bubblewrap_${BUBBLEWRAP_VERSION}_amd64.deb" root="${RUNNER_TEMP}/dsh-bubblewrap" -curl --fail --silent --show-error --location --retry 3 --output "$archive" "$BUBBLEWRAP_URL" +curl --fail --silent --show-error --location --retry 3 --retry-all-errors --output "$archive" "$BUBBLEWRAP_URL" printf '%s %s\n' "$BUBBLEWRAP_SHA256" "$archive" | sha256sum --check --status mkdir -p "$root" dpkg-deb --extract "$archive" "$root" From 6dabf0ea996f218a9c6178cfa666b408821aa9e8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 19:53:51 +0800 Subject: [PATCH 115/229] docs(python): generalize SDK guide --- ...minimal.i18n.yaml => python-sdk.i18n.yaml} | 6 +++--- .../{python-sdk-minimal.md => python-sdk.md} | 20 +++++++++---------- ...hon-sdk-minimal.zh.md => python-sdk.zh.md} | 20 +++++++++---------- docs/user/guide/quickstart.i18n.yaml | 4 ++-- docs/user/guide/quickstart.md | 2 +- docs/user/guide/quickstart.zh.md | 2 +- examples/jsonrpc-agent/README.i18n.yaml | 4 ++-- examples/jsonrpc-agent/README.md | 4 ++-- examples/jsonrpc-agent/README.zh.md | 4 ++-- python/sdk/README.i18n.yaml | 4 ++-- python/sdk/README.md | 2 +- python/sdk/README.zh.md | 2 +- website/docs.ts | 6 +++--- 13 files changed, 40 insertions(+), 40 deletions(-) rename docs/user/guide/{python-sdk-minimal.i18n.yaml => python-sdk.i18n.yaml} (66%) rename docs/user/guide/{python-sdk-minimal.md => python-sdk.md} (82%) rename docs/user/guide/{python-sdk-minimal.zh.md => python-sdk.zh.md} (82%) diff --git a/docs/user/guide/python-sdk-minimal.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml similarity index 66% rename from docs/user/guide/python-sdk-minimal.i18n.yaml rename to docs/user/guide/python-sdk.i18n.yaml index 975a035c74..04cfa163e7 100644 --- a/docs/user/guide/python-sdk-minimal.i18n.yaml +++ b/docs/user/guide/python-sdk.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/user/guide/python-sdk-minimal.md -python-sdk-minimal.md: e658fadae9575fd8bbc2aab2c622df7079c7addf -python-sdk-minimal.zh.md: ef37e1e801512bf60f212e69b61eb423ce4ab1d1 +# pnpm run verify-translation-pairing --write docs/user/guide/python-sdk.md +python-sdk.md: c48bc95c9334cfd16a925d12726c20b2f968c753 +python-sdk.zh.md: dc31c391a180a742c7dc10807f6ed2ef8d11927d diff --git a/docs/user/guide/python-sdk-minimal.md b/docs/user/guide/python-sdk.md similarity index 82% rename from docs/user/guide/python-sdk-minimal.md rename to docs/user/guide/python-sdk.md index e658fadae9..c48bc95c93 100644 --- a/docs/user/guide/python-sdk-minimal.md +++ b/docs/user/guide/python-sdk.md @@ -1,8 +1,8 @@ -# Run the minimal agent with the Python SDK +# Get started with the Python SDK -English | [中文](python-sdk-minimal.zh.md) +English | [中文](python-sdk.zh.md) -This tutorial runs the minimal agent without the Web UI. The checked-in Cordis composition fixes the system prompt, tool catalog, persistent-shell behavior, and compaction policy so SDK runs use the same model-facing contract as the Web `minimal` preset. +This tutorial installs the Python SDK, runs a checked-in Cordis composition without the Web UI, and uses the same API in your own program. It uses the compact [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) configuration as a complete example with a fixed system prompt, tool catalog, persistent-shell behavior, and compaction policy. ## Prerequisites @@ -30,7 +30,7 @@ python -m pip install deepseek-harness-sdk A source build additionally requires Git, Node.js ^22.19 or >= 24, Corepack-enabled pnpm 11, and `uv`. The following commands build the runtime for the current supported host platform, build both wheels, and install them into the active virtual environment: ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git deepseek-harness cd deepseek-harness python -m pip install uv==0.11.23 corepack enable @@ -70,12 +70,12 @@ Run one task from the repository checkout: ```sh python examples/jsonrpc-agent/minimal.py \ --workspace /absolute/path/to/workspace \ - --session-root /absolute/path/to/trajectories \ + --session-root /absolute/path/to/sessions \ --session-id example-001 \ "Inspect the repository and fix the failing tests." ``` -The script prints the final assistant response. The session root receives the JSONL trajectory, including the assembled model request and every tool call. +The script prints the final assistant response. The session root receives a JSONL session log containing the assembled model request and every tool call. ## Use the SDK in your own program @@ -88,7 +88,7 @@ from deepseek_harness import DeepSeekHarness config = Path("examples/jsonrpc-agent/minimal.cordis.yml").resolve() workspace = Path("/absolute/path/to/workspace").resolve() -sessions = Path("/absolute/path/to/trajectories").resolve() +sessions = Path("/absolute/path/to/sessions").resolve() with DeepSeekHarness( provider="deepseek-official", @@ -108,7 +108,7 @@ print(result.final_response) `DeepSeekHarness` starts the bundled JSON-RPC runtime lazily and reuses it until the context manager exits. Reusing the same harness and session id across calls also preserves the session-owned Bash process, including its working directory, exported variables, and shell functions. -## Contract reproduced by the configuration +## Understand the example configuration | Surface | Fixed value | |---|---| @@ -121,9 +121,9 @@ print(result.final_response) The configuration omits harness identity, workspace prompt text, skills, one-shot Bash, task tools, and every other model-facing plugin. Filesystem policy facts are logged as runtime user context rather than appended to the system prompt. The editor requires absolute paths as an unconditional current contract, so the obsolete `requireAbsolutePath` option is absent. -## Keep runs reproducible +## Choose workspace and session IDs -For comparable trajectories, pin the Harness commit and Python package version together, retain the exact Cordis file, and record the provider, model, endpoint, `max_tokens`, task input, workspace state, and session id for every run. Start independent runs with a clean workspace and a fresh session id; reuse a session only when multi-turn state is intentional. +`cwd` selects the workspace available to the agent, while `session_root` stores session logs and state. Use a fresh session id for an independent task; reuse an id only when the next call should continue the same conversation and persistent shell state. The composition uses `danger-full-access`. Run it only inside a disposable checkout or container: Bash and the editor can modify any path allowed to the runtime process. The persistent PTY backend requires a POSIX terminal substrate and is not a Windows agent surface. diff --git a/docs/user/guide/python-sdk-minimal.zh.md b/docs/user/guide/python-sdk.zh.md similarity index 82% rename from docs/user/guide/python-sdk-minimal.zh.md rename to docs/user/guide/python-sdk.zh.md index ef37e1e801..dc31c391a1 100644 --- a/docs/user/guide/python-sdk-minimal.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -1,8 +1,8 @@ -# 使用 Python SDK 运行极简 agent(智能体) +# Python SDK 快速上手 -[English](python-sdk-minimal.md) | 中文 +[English](python-sdk.md) | 中文 -本教程介绍如何在不使用 Web UI 的情况下运行极简 agent。仓库内置的 Cordis 组合固定了系统提示词、工具目录、持久 shell 行为和压缩(compaction)策略,因此 SDK 运行与 Web `minimal` preset 使用相同的面向模型约定。 +本教程介绍如何安装 Python SDK、在不使用 Web UI 的情况下运行仓库内置 Cordis 组合,以及如何在自己的程序中调用同一套 API。教程使用精简且完整的 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 作为示例,其中固定了系统提示词、工具目录、持久 shell 行为和压缩(compaction)策略。 ## 前置要求 @@ -30,7 +30,7 @@ python -m pip install deepseek-harness-sdk 从源码构建还需要 Git、Node.js ^22.19 或 >= 24、通过 Corepack 启用的 pnpm 11,以及 `uv`。以下命令为当前受支持的宿主平台构建运行时和两个 wheel 包,并将它们安装进当前虚拟环境: ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git deepseek-harness cd deepseek-harness python -m pip install uv==0.11.23 corepack enable @@ -70,12 +70,12 @@ export DEEPSEEK_API_KEY=sk-your-key-here ```sh python examples/jsonrpc-agent/minimal.py \ --workspace /absolute/path/to/workspace \ - --session-root /absolute/path/to/trajectories \ + --session-root /absolute/path/to/sessions \ --session-id example-001 \ "Inspect the repository and fix the failing tests." ``` -脚本会打印 assistant 的最终回复。会话根目录会收到 JSONL 运行轨迹,其中包含组装后的模型请求与每次工具调用。 +脚本会打印 assistant 的最终回复。会话根目录会收到 JSONL 会话日志,其中包含组装后的模型请求与每次工具调用。 ## 在自己的程序中使用 SDK @@ -88,7 +88,7 @@ from deepseek_harness import DeepSeekHarness config = Path("examples/jsonrpc-agent/minimal.cordis.yml").resolve() workspace = Path("/absolute/path/to/workspace").resolve() -sessions = Path("/absolute/path/to/trajectories").resolve() +sessions = Path("/absolute/path/to/sessions").resolve() with DeepSeekHarness( provider="deepseek-official", @@ -108,7 +108,7 @@ print(result.final_response) `DeepSeekHarness` 会延迟启动内置 JSON-RPC 运行时,并持续复用,直至退出上下文管理器。在多次调用中复用同一个 harness 和 session id,还会保留该会话拥有的 Bash 进程,包括其工作目录、已导出的变量与 shell 函数。 -## 配置复现的约定 +## 了解示例配置 | 方面 | 固定值 | |---|---| @@ -121,9 +121,9 @@ print(result.final_response) 该配置省略了 harness 身份、workspace 提示词文本、skill(技能)、一次性 Bash、任务工具和其他所有面向模型的插件。文件系统策略事实记录为运行时用户上下文,而不会追加到系统提示词中。编辑器无条件要求绝对路径,因此配置中没有已经废弃的 `requireAbsolutePath` 选项。 -## 保持运行可复现 +## 选择 workspace 与 session id -为了让运行轨迹可复现且便于比较,请配套固定 Harness commit 与 Python 包版本,保留确切的 Cordis 文件,并为每次运行记录提供方、模型、端点、`max_tokens`、任务输入、workspace 状态和 session id。独立运行应使用干净的 workspace 和新的 session id;只有有意保留多轮状态时才复用会话。 +`cwd` 用于选择 agent 可访问的 workspace,`session_root` 用于保存会话日志和状态。独立任务应使用新的 session id;只有下一次调用需要延续同一段对话和持久 shell 状态时,才复用原有 id。 该组合使用 `danger-full-access`。只能在可丢弃的 checkout 或容器内运行:Bash 与编辑器可以修改运行时进程有权访问的任何路径。持久 PTY 后端需要 POSIX 终端环境,因此该模式不适用于 Windows agent。 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index a52f07e5c7..5aa765be30 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md -quickstart.md: 13d5b2196282394619747e36ecddfa1d87f61c8d -quickstart.zh.md: 3d775db9280b43bbee2de37f7327fe8d2bd3a121 +quickstart.md: 6a0b292ce12b32b7993b7de56b35f1df2e7a7153 +quickstart.zh.md: 008245f136e28630c7e8368eeec536e11112a885 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 13d5b21962..6a0b292ce1 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -57,7 +57,7 @@ Open `http://127.0.0.1:3080`. The agent can read and write files, run commands, ## Next steps -- [Run the minimal agent with Python](./python-sdk-minimal.md) — use the fixed two-tool composition without the Web UI +- [Get started with the Python SDK](./python-sdk.md) — install the SDK and run a complete Cordis configuration without the Web UI - [Configure models](./providers.md) — reach providers beyond DeepSeek, and custom gateways - [Configuration](./config.md) — understand the `cordis.yml` format - [Develop a plugin](../develop/basic/) — build your own tool or backend diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 3d775db928..008245f136 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -57,7 +57,7 @@ pnpm run dsh web ## 下一步 -- [使用 Python 运行极简 agent](./python-sdk-minimal.md) — 无需 Web UI,即可使用固定的双工具组合 +- [Python SDK 快速上手](./python-sdk.md) — 安装 SDK,并在不使用 Web UI 的情况下运行完整 Cordis 配置 - [配置模型](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 - [配置文件](./config.md) — 了解 `cordis.yml` 的格式 - [开发插件](../develop/basic/) — 编写自己的工具或后端 diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml index 8da5cf7ae6..04ee95bfab 100644 --- a/examples/jsonrpc-agent/README.i18n.yaml +++ b/examples/jsonrpc-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/jsonrpc-agent/README.md -README.md: 863b39eb9c7c65d36ceca77e945379fdd1d5fe22 -README.zh.md: a8334320896d2b16e166ef0f0ec300ab1cb0ca93 +README.md: 5f60a64a4888c64e4fd68835f78e4a334ffed263 +README.zh.md: 8d2f9807ff259000b5a6823357f8c41b43bfa434 diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md index 863b39eb9c..5f60a64a48 100644 --- a/examples/jsonrpc-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -21,7 +21,7 @@ The surrounding runtime also loads JSONL session persistence and automatic conte | `DEEPSEEK_BASE_URL` | Host endpoint used by `dsh-llm-deepseek` | | `DSH_CWD` | Agent workspace for bash and filesystem tools | | `DSH_MAX_TOKENS_AS_SUCCESS` | `true` (default) accepts token-limited results; `false` reports them as errors | -| `DSH_SESSION_ROOT` | JSONL trajectory directory | +| `DSH_SESSION_ROOT` | JSONL session directory | | `DSH_SYSTEM_PROMPT` | Deployment-provided coding persona | Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js. @@ -33,4 +33,4 @@ Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CON - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -It composes the local PTY, filesystem intent policy, session sandbox policy, and JSONL persistence needed by the bundled runtime. [`minimal.py`](minimal.py) runs it through the Python SDK; the [minimal Python SDK tutorial](../../docs/user/guide/python-sdk-minimal.md) covers setup, repeatable runs, and the security boundary. +It composes the local PTY, filesystem intent policy, session sandbox policy, and JSONL persistence needed by the bundled runtime. [`minimal.py`](minimal.py) runs it through the Python SDK; the [Python SDK tutorial](../../docs/user/guide/python-sdk.md) uses this configuration to cover setup, session management, and the security boundary. diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md index a833432089..8d2f9807ff 100644 --- a/examples/jsonrpc-agent/README.zh.md +++ b/examples/jsonrpc-agent/README.zh.md @@ -21,7 +21,7 @@ | `DEEPSEEK_BASE_URL` | `dsh-llm-deepseek` 使用的宿主端点 | | `DSH_CWD` | bash 和文件系统工具使用的 agent workspace | | `DSH_MAX_TOKENS_AS_SUCCESS` | `true`(默认)接受受 token 上限限制的结果;`false` 将其报告为错误 | -| `DSH_SESSION_ROOT` | JSONL 轨迹目录 | +| `DSH_SESSION_ROOT` | JSONL 会话目录 | | `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 | 通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件中指定的每个插件;目标机器无需 Node.js。 @@ -33,4 +33,4 @@ - 所有者作用域内持久化的 `bash` - 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -它组合了内置运行时所需的本地 PTY、文件系统意图策略、会话沙箱策略与 JSONL 持久化。[`minimal.py`](minimal.py) 通过 Python SDK 运行该配置;[极简 Python SDK 教程](../../docs/user/guide/python-sdk-minimal.md)介绍设置方式、可重复运行与安全边界。 +它组合了内置运行时所需的本地 PTY、文件系统意图策略、会话沙箱策略与 JSONL 持久化。[`minimal.py`](minimal.py) 通过 Python SDK 运行该配置;[Python SDK 教程](../../docs/user/guide/python-sdk.md)以此配置介绍设置方式、会话管理与安全边界。 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 08da23d879..895fea6cfc 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk/README.md -README.md: 2350fbfd5dd0094bfd7a11f81d8226e960879923 -README.zh.md: 5120a8c5f65360485d450614186d5cb2702fda17 +README.md: 9640c7e8dfd011b94acdc781ae0e4fdc8ad87378 +README.zh.md: 47ac04f9083ef41e23fda8ec527c1da160fe4769 diff --git a/python/sdk/README.md b/python/sdk/README.md index 2350fbfd5d..9640c7e8df 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -40,7 +40,7 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. -The [minimal-agent tutorial](../../docs/user/guide/python-sdk-minimal.md) provides a complete standalone Cordis file and runnable SDK example for using the two-tool minimal mode without the Web UI. +The [Python SDK tutorial](../../docs/user/guide/python-sdk.md) uses a complete standalone Cordis file to demonstrate installation, direct SDK usage, and runs without the Web UI. `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, events, notifications, session_root)`. The result has no prompt-level status or turn reason: `final_response` is the last committed root-session assistant text in the interval, not an output causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 5120a8c5f6..47ac04f908 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -37,7 +37,7 @@ with DeepSeekHarness( `provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。`max_tokens` 是可选的正整数,用于限制根 agent(智能体)及其进程内后代每次请求的输出 token;省略时由提供方默认值控制。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 -[极简 agent 教程](../../docs/user/guide/python-sdk-minimal.md)提供完整的独立 Cordis 文件与可运行的 SDK 示例,用于在不使用 Web UI 的情况下使用双工具极简模式。 +[Python SDK 教程](../../docs/user/guide/python-sdk.md)使用完整的独立 Cordis 文件演示安装方式、直接调用 SDK,以及在不使用 Web UI 的情况下运行 agent。 `Session.run()` 拥有一个从提示词进入持久 inbox 时开始、到整个 agent 下一次进入空闲状态为止的活动区间,并返回 `RunResult(session_id, final_response, events, notifications, session_root)`。结果不携带提示词级状态或轮次原因:`final_response` 是该区间内根会话最后提交的助手文本,并非因果上归属于该提示词的输出。steering(中途引导)、注入的上下文和其他排队工作都可能在进入空闲状态前参与其中。 diff --git a/website/docs.ts b/website/docs.ts index 365df21571..0019fd3a28 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -139,9 +139,9 @@ const homeAndGuide = pairedPages([ order: 3, }, { - source: 'docs/user/guide/python-sdk-minimal.md', - route: 'guide/python-sdk-minimal.md', - label: { root: 'Python SDK 极简模式', en: 'Minimal mode with Python' }, + source: 'docs/user/guide/python-sdk.md', + route: 'guide/python-sdk.md', + label: { root: 'Python SDK', en: 'Python SDK' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, order: 4, From 9ce8340dd9721b0986e00404c5264dcd1e3ccef9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 19:53:58 +0800 Subject: [PATCH 116/229] fix(web): pin preset before subagent header action --- apps/web/tests/agent-preset-selection.e2e.ts | 63 ++++++++++++++++++- .../agent-preset-selection/header.expected.md | 3 + .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 +- packages/client/ui-agent-preset/README.zh.md | 2 +- .../ui-agent-preset/src/client/index.ts | 3 +- .../ui-agent-preset/tests/apply.spec.ts | 2 +- .../src/client/contract/slots.ts | 6 +- 8 files changed, 77 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 553a21bb6f..4a7f6eb819 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -16,6 +16,10 @@ 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 { + SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionId, +} from '@deepseek-ai/dsh-session' +import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, @@ -78,6 +82,60 @@ function seedLog(): string { ].join('\n') } +/** + * Persist one child so the assembled header snapshot exercises both action + * contributors whose relative order is the product contract under test. + * @param scaffold - the booted Web scaffold. + * @param parentId - the seeded session whose header the browser opens. + */ +async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise<void> { + const childId = sessionId('agent-preset-selection-child') + const createdAt = 1784974100100 + await scaffold.ctx.sessionPersistence.create({ + version: SESSION_FORMAT_VERSION, + id: childId, + createdAt, + cwd: scaffold.workspaceCwd, + parentSession: parentId, + origin: 'subagent', + delegationDepth: 1, + agentPreset: 'minimal', + }) + await scaffold.ctx.sessionPersistence.append(childId, [ + { + type: 'turn/start', + seq: 0, + time: createdAt, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }, + { + type: 'user/message', + seq: 1, + time: createdAt + 1, + data: { + content: [{ type: 'text', text: 'Check the session-header action order.' }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + }, + { + type: 'subagent/descriptor', + seq: 2, + time: createdAt + 2, + data: snapshotSubagentDescriptor({ + mode: 'one-shot', provider: 'spawn', label: 'header order probe', + }), + }, + { + type: 'turn/end', + seq: 3, + time: createdAt + 3, + data: { turn: 1, reason: { kind: 'completed' } }, + }, + ] as SessionEvent[]) + await scaffold.ctx.sessionProjectionCache.coldSnapshot(childId) +} + /** * The preset the host reports for the blank session the workspace connect * produced. Addressed by id rather than by scanning the serialized list: the @@ -120,7 +178,8 @@ describe('web e2e: agent-preset selection', () => { // A resumed session runs what it was created with; seeding one that // records `minimal` is what makes the header label a claim about the // session rather than an echo of the current default. - await seedSession(scaffold, seedLog(), SEED_ID, 'minimal') + const seededId = await seedSession(scaffold, seedLog(), SEED_ID, 'minimal') + await seedSubagent(scaffold, seededId) await seedWorkspaceSkill(scaffold.workspaceCwd) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -222,6 +281,8 @@ describe('web e2e: agent-preset selection', () => { await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Minimal mode') + expect(snapshot).toContain('button "1 subagent"') + expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"')) // Static chrome, not a control: the header can only report a composition // the host would refuse to change. expect(snapshot).not.toContain('button "Minimal mode"') diff --git a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md index d627a21945..5a78c6461e 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md @@ -2,3 +2,6 @@ - button "Seeded turn" [disabled] - img - text: Minimal mode +- button "1 subagent": + - text: 1 subagent + - img diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index a7377027a2..ca91d9f97e 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: c4c7df4e6fbe0479cac4767247c1b10fd65aad77 -README.zh.md: 84c02977ec18f89c06311b570428f92c2b459fb3 +README.md: 008066114e9c49e5c74299979e24c27a4c9621c9 +README.zh.md: e07d5994ae196cd03be7818fe4ade1aafda9aa55 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index c4c7df4e6f..008066114e 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -18,7 +18,7 @@ A session that has started is refused rather than queued: the host answers `agen ## The session-header label -A third surface, beside the session title: the preset THIS session runs, as static chrome. It precedes the subagent catalog in the header action row. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads. +A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads. ## What it reads and writes diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 84c02977ec..e07d5994ae 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -18,7 +18,7 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于 ## 会话标题旁的标签 -第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。它在头部操作行中排在 subagent 列表之前。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。 +第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。 ## 它读什么、写什么 diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 0737992337..913639e9ab 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -157,7 +157,8 @@ export function apply(ctx: ClientContext): void { const label = scope.slots.register({ name: 'conversation.session.header.actions', id: 'agent-preset', - order: 0, + // Static session context occupies the header's leading negative-order band. + order: -10, locale: 'settings.agentPreset', inject: labelInjected, }, AgentPresetLabel) diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index 4a6f58075f..23e1944948 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -304,7 +304,7 @@ describe('ui-agent-preset apply', () => { expect(chip.component).toBe(AgentPresetSeat) const label = slots.entries('conversation.session.header.actions')[0]! expect(label.component).toBe(AgentPresetLabel) - expect(label.options).toMatchObject({ id: 'agent-preset', order: 0 }) + expect(label.options).toMatchObject({ id: 'agent-preset', order: -10 }) await fiber.dispose() expect(slots.entries('conversation.hero.agentPreset')).toHaveLength(0) expect(slots.entries('conversation.session.header.actions')).toHaveLength(0) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index c0749fcb46..bbca9254bc 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -38,7 +38,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'conversation.session': { kind: 'single'; scope: 'session' } /** Strict-session header above the resident conversation scrollport. */ 'conversation.session.header': { kind: 'single'; scope: 'session' } - /** Session-header actions contributed by feature plugins. */ + /** + * Session-header actions contributed by feature plugins. Entries render + * by ascending `order`; negative values are reserved for static session + * context that precedes interactive actions. + */ 'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps } /** * The conversation view ring: one list entry per view tab (chat here; From eacd5e216798f0e69eec82226e28a50a1c83de24 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 20:09:06 +0800 Subject: [PATCH 117/229] fix(prompt): remove unreachable complete branches --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 ++-- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- .../agent-presets/minimal/agent.cordis.yml | 7 +++--- packages/core/system-prompt/src/index.ts | 24 +++++++++---------- packages/preset/persona/src/index.ts | 2 +- 6 files changed, 19 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index fbe84849b4..8753c066cf 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 2375ad7e40afb096d7e1bbec4de023433de1e012 -2026-07-29-persistent-bash-str-replace-editor.zh.md: fcabc4bd342224a8b2d024a48901af284b4c6d2e +2026-07-29-persistent-bash-str-replace-editor.md: 2c077a08e6027245779a0db364c83d17a9c74fce +2026-07-29-persistent-bash-str-replace-editor.zh.md: f642f2100cbc40ddf688400c5e6124ca9a6ff72d diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 2375ad7e40..2c077a08e6 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -18,7 +18,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable. -The shipped [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes native presentation and the complete system prompt, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary. +The shipped [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes the complete system prompt, follows the deployment tool-presentation mode, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index fcabc4bd34..f642f2100c 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -18,7 +18,7 @@ Status: implemented 两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。 -随附的 [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定原生呈现和完整系统提示词,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)负责说明。 +随附的 [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定完整系统提示词、跟随部署的工具呈现模式,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)负责说明。 ## 考虑过的替代方案 diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 44d1bb45df..b03b689445 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -41,15 +41,14 @@ * Please avoid commands that may produce a very large amount of output. * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. -# Absolute paths are unconditional in the current editor; the legacy -# `requireAbsolutePath` switch is no longer a configuration field. +# The editor requires absolute paths unconditionally. - id: str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' config: maxOutputChars: 16000 -# RL core's fixed 128K window now comes from the routed model metadata rather -# than compact-basic config. Its remaining policy is preserved explicitly. +# Model capacity comes from routed model metadata; this block states the +# compaction policy explicitly. - id: compaction name: cordis:group group: true diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 22bcd1aa9d..afede84e50 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -478,23 +478,21 @@ export class SystemPrompt extends Service { collected.push(...schemas) for (const name of acceptedKnownNames) knownNames.add(name) } - const completeSections = [...sectionByName.values()].filter(section => section.complete === true) + const sectionDefinitions = [...sectionByName.values()].sort((a, b) => a.order - b.order) + const completeSections = sectionDefinitions.filter(section => section.complete === true) if (completeSections.length > 1) { throw new Error(`multiple complete prompt sections are active: ${completeSections.map(section => JSON.stringify(section.name)).join(', ')}`) } - const sections = [...sectionByName.values()] - .sort((a, b) => a.order - b.order) - .map(section => ({ - name: section.name, - text: typeof section.text === 'function' ? section.text(context) : section.text, - })) - const completeName = completeSections[0]?.name let completeSection: AssembledSection | undefined - if (completeName !== undefined) { - const assembled = sections.find(section => section.name === completeName) - if (assembled === undefined) throw new Error(`complete prompt section ${JSON.stringify(completeName)} did not assemble`) - completeSection = { ...assembled } - } + const sections = sectionDefinitions + .map((section) => { + const assembled = { + name: section.name, + text: typeof section.text === 'function' ? section.text(context) : section.text, + } + if (section.complete === true) completeSection = { ...assembled } + return assembled + }) const assembly: PromptAssembly = { sections, contexts: [...contextByName.values()] diff --git a/packages/preset/persona/src/index.ts b/packages/preset/persona/src/index.ts index a76238033d..027aa89d66 100644 --- a/packages/preset/persona/src/index.ts +++ b/packages/preset/persona/src/index.ts @@ -59,6 +59,6 @@ export function apply(ctx: Context, config: Config): void { name: PERSONA_SECTION, order: PERSONA_ORDER, text: config.text, - complete: config.complete ?? false, + ...(config.complete ? { complete: true } : {}), }), 'persona.section()') } From 1478d3f806856b47f39b9e49c20f913a4c119605 Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Mon, 10 Aug 2026 20:14:16 +0800 Subject: [PATCH 118/229] test(web): refresh subagent conversation goldens for the pinned-approval Custom chip The delegation-pinned approval/policy: never makes a child session's knobs match no preset, so the child conversation's Access chip truthfully reads Custom. --- apps/web/tests/snapshots/subagent-conversation/ui.expected.md | 2 +- .../snapshots/subagent-interrupt/offline-composer.expected.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 27c7ec092e..d581a7ab1f 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -41,7 +41,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Workspace Write"': Workspace Write +- 'button "Access mode, current: Custom"': Custom - button "6% of context used" - button "Send message" [disabled] - text: 2 turns · 2 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.6K tok · Output 158 tok diff --git a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md index a977afbbea..529e6bb43d 100644 --- a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md +++ b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md @@ -18,6 +18,6 @@ - textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled] - button "Commands" [disabled]: - img -- 'button "Access mode, current: Workspace Write" [disabled]': Workspace Write +- 'button "Access mode, current: Custom" [disabled]': Custom - button "Stop generating" - button "Send message" [disabled] From 62f4da95f50ade285de19dcb009b21fbbb48b129 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 20:14:39 +0800 Subject: [PATCH 119/229] test(preset): assert minimal compaction policy --- apps/cli/tests/web-agent-presets.e2e.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 4a91016bf7..375ccfb3e1 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -12,6 +12,7 @@ import { beforeAll, describe, expect, it } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' import { CallId } from '@deepseek-ai/dsh-llm' +import type { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-tools' @@ -166,6 +167,16 @@ describe('the shipped Web composition', () => { expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION) expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters)) .toContain('Absolute path') + const compact = ctx.agentPresets.serviceFor(handle.agent, 'compact') + expect(compact).toBeDefined() + expect((compact as BasicCompactService).config).toMatchObject({ + thresholdRatio: 0.8, + retainTokens: 20480, + summarizationProvider: '', + summarizationModel: '', + maxTokens: 8192, + compactionRetries: 1, + }) } finally { await handle.dispose() } @@ -355,18 +366,6 @@ describe('the shipped Web composition', () => { expect(await readFile(path, 'utf8')).toBe(before) }) - it('gives each session its own complete persona', async () => { - const handle = await ctx.agents.create({ - sessionId: SessionId('preset-persona'), - setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), - }) - try { - const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent }) - expect(assembly.sections).toEqual([{ name: 'deployment:persona', text: MINIMAL_PROMPT }]) - } finally { - await handle.dispose() - } - }) }) describe('a switch survives the session', () => { From 7ca87515847acceff2a3f4ef9eb12e0f9e556a94 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 20:20:31 +0800 Subject: [PATCH 120/229] test(web): refresh localized preset snapshots --- apps/web/tests/snapshots/code-mode-round/ui.expected.md | 2 +- apps/web/tests/snapshots/cordis-tool-round/ui.expected.md | 2 +- apps/web/tests/snapshots/fresh-round-trip/ui.expected.md | 2 +- .../tests/snapshots/goal-multi-turn-actions/ui.expected.md | 2 +- apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md | 4 ++-- .../tests/snapshots/lifecycle-chrome/plan-active.expected.md | 4 ++-- .../web/tests/snapshots/lifecycle-chrome/reloaded.expected.md | 2 +- apps/web/tests/snapshots/live-interactions/cancel.expected.md | 2 +- .../tests/snapshots/live-interactions/error-auth.expected.md | 2 +- .../web/tests/snapshots/live-interactions/loading.expected.md | 2 +- apps/web/tests/snapshots/live-interactions/retry.expected.md | 2 +- apps/web/tests/snapshots/plan-review/approved.expected.md | 2 +- .../tests/snapshots/question-composer/answered.expected.md | 2 +- apps/web/tests/snapshots/queue-actions/collapsed.expected.md | 2 +- apps/web/tests/snapshots/queue-actions/editing.expected.md | 2 +- apps/web/tests/snapshots/queue-actions/layout.expected.md | 2 +- apps/web/tests/snapshots/queue-actions/preserved.expected.md | 2 +- apps/web/tests/snapshots/queue-actions/ui.expected.md | 2 +- apps/web/tests/snapshots/skill-user-invoke/ui.expected.md | 2 +- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 2 +- apps/web/tests/snapshots/steer-all/settled.expected.md | 2 +- apps/web/tests/snapshots/steering/mid-steer.expected.md | 2 +- apps/web/tests/snapshots/steering/settled.expected.md | 2 +- .../web/tests/snapshots/turn-tail-actions/running.expected.md | 2 +- .../web/tests/snapshots/turn-tail-actions/settled.expected.md | 2 +- apps/web/tests/snapshots/web-search-round/ui.expected.md | 2 +- apps/web/tests/steering.e2e.ts | 2 +- 27 files changed, 29 insertions(+), 29 deletions(-) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index f426539e87..9d9ea8ab0e 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - 'button "Using ONE run_code program: run" [disabled]' - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 2bc6f76a93..42197364ae 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use only Cordis tools. First" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 0529e000b7..d360f936bd 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use the bash tool to" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md index fa178e30d8..c0ece71be8 100644 --- a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md +++ b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "workspace" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 223006d59d..c666681b0b 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -25,9 +25,9 @@ - img - text: workspace - img -- button "标准模式": +- button "Standard mode": - img - - text: 标准模式 + - text: Standard mode - img - textbox "Describe what you want to build" - button "Commands": diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index a234028a16..2f9c701936 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -25,9 +25,9 @@ - img - text: workspace - img -- button "标准模式": +- button "Standard mode": - img - - text: 标准模式 + - text: Standard mode - img - textbox "Describe what you want to build" - button "Commands": diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 149d8ce3f9..19283ae51d 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with the single word" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 1d87f01525..4fa0394693 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 94d739baf1..83be4dc961 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index c461dd985a..c475c05e05 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 70a9e69e95..7bb61c5b27 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index cdd844062a..23664b4a1d 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - 'button "Plan a small change: add" [disabled]' - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 32e3e4bc75..d15b2af3a1 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index d039c3d6d6..5438f084ae 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 453b83e20e..186f0b0169 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md index 64451ab4ce..9996bdcd0b 100644 --- a/apps/web/tests/snapshots/queue-actions/layout.expected.md +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "workspace" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index a845590873..54410743dc 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 386b3e9889..1467c50d97 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with a one-sentence description" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md index 63bd2ef401..1f1cd0ea18 100644 --- a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "/user-invoke-demo and confirm the fixtur" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 8b77a77a0c..ce2d4a66e6 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index 0899529a09..b20e590686 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 5fd1fccaaf..30d4d7ac4b 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index cba73282f3..93c311cce4 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/turn-tail-actions/running.expected.md b/apps/web/tests/snapshots/turn-tail-actions/running.expected.md index b1a32406de..8e5d4c5858 100644 --- a/apps/web/tests/snapshots/turn-tail-actions/running.expected.md +++ b/apps/web/tests/snapshots/turn-tail-actions/running.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Begin your reply with the" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md index 32ea9a9b1e..47203f70eb 100644 --- a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md +++ b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Begin your reply with the" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index b24385d48f..9e86cdcf2c 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Use web_search to search exactly" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index a527081fbe..01b3a85b56 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -314,7 +314,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) - await page.getByText('标准模式', { exact: true }).waitFor({ timeout: 10_000 }) + await page.getByText('Standard mode', { exact: true }).waitFor({ timeout: 10_000 }) }, 120_000) afterAll(async () => { From 43f3324a7beaa7ef3de8e7fa86fdb3ff3841febc Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 20:34:45 +0800 Subject: [PATCH 121/229] fix(tools): restrict what a scope inherits, not just the global layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A restriction was compiled against the global tool layer alone: only global-layer tools were tested against `admits()`, and every chain-layer tool was overlaid unfiltered afterward. That read the exempt set as "the global layer" when what it means is "what this scope registers itself" — two descriptions of the same set only while every model-facing tool sat in the host composition. Moving those rows onto the agent plane separated them. A preset's tools are an ANCESTOR contribution to a joined agent, so a subagent's `toolFilter` stopped constraining anything it was given; and with the global layer empty `restrict()` rejected every name it received as unknown, failing the child outright. With the same tools in the global layer the filter still admits and applies normally, which is what makes this a regression of the move rather than a standing limitation. `view()` now filters everything a scope inherits — the global layer and every ancestor layer on its chain — and exempts only the layer the scope owns. That exemption is load-bearing rather than incidental: the delegation runtime registers a child's `report` and structured-output tools into the child's own layer, and a filter naming the capabilities the child may use must not strip the machinery it answers through. Tool order, and with it prefix-cache reuse, is unchanged: inherited names keep their global-then- ancestor position and own-layer names still come last. The diagnostic said "unknown global tool" while listing what is really the inherited surface; it now names the surface it checks and says why an own-layer name is not restrictable. Fixes #2185 --- ...-agents-join-their-parent-preset.i18n.yaml | 4 +- ...0-child-agents-join-their-parent-preset.md | 12 ++- ...hild-agents-join-their-parent-preset.zh.md | 12 ++- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 15 ++-- docs/subsystems/tools.zh.md | 15 ++-- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/index.ts | 59 ++++++++++----- packages/core/tools/tests/scoped.spec.ts | 75 +++++++++++++++++-- .../tests/preset-inheritance.spec.ts | 15 ++++ .../tests/subagent-inprocess.spec.ts | 2 +- .../tests/subagent-spawn.spec.ts | 2 +- 14 files changed, 170 insertions(+), 53 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml index 34697cd123..351632fcc5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-child-agents-join-their-parent-preset.md -2026-08-10-child-agents-join-their-parent-preset.md: d9aa0dc43c1338d3198f5335da6ad238730d58a1 -2026-08-10-child-agents-join-their-parent-preset.zh.md: dd85c642ff7e6e2934e805c2efaccdc6dda63f15 +2026-08-10-child-agents-join-their-parent-preset.md: 4534004ad54df69822872b9595a29443fc3a990b +2026-08-10-child-agents-join-their-parent-preset.zh.md: bdf9928bea4b75e2915c8adf5c15f8a01c6583e4 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md index d9aa0dc43c..4534004ad5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md @@ -22,6 +22,8 @@ This is a bind, not a mount, and both differences are load-bearing. The child ge `dsh-subagent` reaches the roster through `ctx.get('agentPresets')` with a type-only import and an optional peer dependency — the documented opportunistic-consumption pattern it already uses for `sandboxPolicy` and `approval`. +Giving the child its parent's tools exposed a second defect the same agent-plane move introduced: `ToolRegistry` exempted SCOPED registrations from a restriction and filtered only the global layer, so once every model-facing row became an ancestor contribution, a child's `toolFilter` stopped constraining anything — and, with the global layer empty, `restrict()` rejected every name it was given as unknown, failing the child outright. The exempt set is the tools a scope registers ITSELF, not the tools that happen to live in the global layer; reading it the second way held only while those two sets coincided. `view()` now filters everything a scope inherits — the global layer and every ancestor layer — and exempts only its own. The own-layer exemption is load-bearing rather than incidental: the delegation runtime registers a child's `report` and structured-output tools into the child's own layer, and a filter naming the capabilities the child may use must not strip the machinery it answers through. + ## Alternatives considered **Re-mount the parent's preset by id in the child's setup.** Rejected on both semantics and mechanics. It re-reads the roster and re-stats the composition file, so an edit since the parent started forks the child onto a different generation, and a preset deleted since fails the child while its parent runs on. `mount()` is also asynchronous, which the synchronous creation windows cannot accept without restructuring both drivers. @@ -32,22 +34,26 @@ This is a bind, not a mount, and both differences are load-bearing. The child ge **Let `dsh-subagent` import `resolveSessionPreset` and mount by the resolved id.** Rejected because it makes the preset roster a hard module edge for a package that must work without one, and it lands back on the remount semantics above. +**Filter every layer on the chain, including the scope's own.** Rejected because it makes a per-child capability filter delete that child's reporting and structured-output tools, which the delegation runtime registers into the child's own layer — an `allow` naming the capabilities a child may use would leave it unable to answer at all. + **Leave the durable header alone and fix only the live join.** Rejected because the live child and the same child read cold would then disagree about which composition produced its history — the same class of defect, moved rather than fixed. ## Testing `packages/preset/agent-presets/tests/mount.spec.ts` covers the join against real fixture compositions: the child sees its parent's tools and prompt sections, no second generation is mounted, the join survives the parent's disposal (a background child outliving its parent), the reported id matches, a parent without a preset joins nothing, and an unscoped context is refused. -`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, and a parent that switched preset while blank — to a DIFFERENT preset, so the assertion distinguishes reading the parent's live scope chain from reading its creation header. +`packages/core/tools/tests/scoped.spec.ts` covers the restriction rule directly: a child's filter removes a tool it inherited from an ancestor scope, the child's own registrations survive its own filter, and an ancestor's restriction still reaches every scope nested inside it. + +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` asserts the model-visible result through `startInProcessRun()` on a host composition carrying no model-facing rows: the schemas in the child's own request, its parent's prompt section, the recorded header preset, a `toolFilter` applied over the inherited preset tools, and a parent that switched preset while blank — to a DIFFERENT preset, so the assertion distinguishes reading the parent's live scope chain from reading its creation header. The assembled-transcript layer is the shipped Web composition's e2e rather than a keyless snapshot. Every runnable example this repo ships composes no preset roster, so the defect is not observable in the snapshot harness at all: a snapshot scenario would first need an example that mounts a roster AND delegates. The Web e2e boots the real `base` + `web-app` patch layers with both shipped presets, which is the assembled evidence the testing policy asks for; the Web browser lane's subagent goldens carry the visible consequence, since a child that records its preset now shows the preset badge its parent shows. ## Consequences -Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's — the per-child `toolFilter` does not narrow them, for the separately tracked reason below; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join. +Delegation now costs a scope-parent bind per child and nothing else — no extra plugin instances, no roster read, no failure mode. A child's capabilities are exactly its parent's, minus whatever its own `toolFilter` removes; a per-subagent preset ("agent types") remains unbuilt and would be a new request field rather than a change to this join. `applyChildComposition()` changed shape, so any future out-of-tree in-process driver must supply the parent. That is the intended cost: the previous signature let a caller compose a capability-less child and get no error. A cold-resumed continuable child joins its parent's CURRENT composition rather than the one its own header records. The window is narrow — the parent must create the child, stay blank, switch preset, and only then wake it, since a resident child never re-joins and a one-shot child never resumes — and the alternative is worse: resolving the child's own recorded id would re-read the roster and hand back the preset-deleted failure mode this join exists to avoid. The child's header still records what it started under, so the divergence is observable rather than silent. -`toolFilter` does not constrain a joined child, because `ToolRegistry` compiles restrictions against global-layer names only and overlays chain-layer tools unfiltered. That is not new here — with the roster composed, `tools.restrict()` already rejected every name as an unknown global tool, so a child carrying a filter failed to start both before and after this change — but it is a regression from the agent-plane move rather than a standing limitation: with the same tools registered in the global layer, the filter admits and applies normally. It matters more now that the child has its parent's full tool set to be restricted from. It is tracked separately; this change neither introduces nor repairs it. +`ToolRegistry` now reads a restriction's exempt set as "what this scope registers itself" rather than "the global layer", which changes one documented behavior beyond delegation: a tool an ANCESTOR scope contributes is now subject to a descendant's filter, where before only global-layer tools were. Nothing else on the chain loses its exemption — a scope's own registrations stay outside its own filter, which is the property the delegation runtime depends on. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md index dd85c642ff..bdf9928bea 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md @@ -22,6 +22,8 @@ Status: implemented `dsh-subagent` 以类型级导入加可选 peer 依赖的方式,通过 `ctx.get('agentPresets')` 触达 roster——这正是它对 `sandboxPolicy` 与 `approval` 已在使用的、有明确文档的机会性消费模式。 +把父方的工具交给子 agent 之后,暴露出同一次 agent 平面搬迁引入的第二个缺陷:`ToolRegistry` 把**作用域级**注册排除在限制之外、只过滤全局层,因此当所有面向模型的行都变成祖先贡献之后,子 agent 的 `toolFilter` 就不再约束任何东西——而且全局层为空时,`restrict()` 会把收到的每个名字都判为未知并直接让子 agent 创建失败。豁免集合应当是作用域**自己注册**的工具,而不是恰好位于全局层的工具;后一种读法只在这两个集合重合时才成立。`view()` 现在过滤作用域继承来的一切——全局层与每个祖先层——只豁免它自己那层。这条自身层豁免是承重的而非顺带的:委派运行时把子 agent 的 `report` 与结构化输出工具注册进子 agent 自己那层,而一个只点名子 agent 可用能力的过滤器绝不能把它回报所依赖的机制一并剥掉。 + ## Alternatives considered **在子 agent 的 setup 里按 id 重新挂载父方的 preset。** 语义与机制两方面都不成立而被否决。它会重读 roster 并重新 stat 组装文件,因此父方启动后的一次编辑就会把子 agent 分叉到另一个代际,而此后被删除的 preset 会让子 agent 失败、父方却照常运行。`mount()` 还是异步的,同步的创建窗口无法在不重构两个驱动的前提下接受它。 @@ -32,22 +34,26 @@ Status: implemented **让 `dsh-subagent` 导入 `resolveSessionPreset` 并按解析出的 id 挂载。** 否决,因为这会给一个必须在没有 roster 时也能工作的包引入硬模块边,而且最终仍落回上述的重新挂载语义。 +**过滤链上的每一层,包括作用域自身那层。** 否决,因为那会让逐子 agent 的能力过滤器把该子 agent 的回报与结构化输出工具一并删掉——它们由委派运行时注册进子 agent 自己那层——于是一个点名"子 agent 可用哪些能力"的 `allow` 会让它彻底无法回报。 + **只修活着的加入,不动持久化 header。** 否决,因为那样活着的子 agent 与冷读同一个子 agent 会对"哪份组装产出了这段历史"给出不同答案——同一类缺陷,只是被搬了个地方而不是被修掉。 ## Testing `packages/preset/agent-presets/tests/mount.spec.ts` 用真实 fixture 组装覆盖该加入:子 agent 看到父方的工具与提示段、不会挂载出第二个代际、加入在父方 dispose 后依然成立(活得比父方久的后台子 agent)、上报的 id 一致、没有 preset 的父方不产生加入、以及无 scope 的上下文被拒绝。 -`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset,以及在空白期切换过 preset 的父方——切换到**另一个** preset,这样断言才能区分"读父方活 scope 链"与"读父方创建 header"。 +`packages/core/tools/tests/scoped.spec.ts` 直接覆盖该限制规则:子 agent 的过滤器能移除它从祖先作用域继承来的工具、子 agent 自身的注册在自己的过滤器下存活、祖先的限制仍作用于其内嵌套的每个作用域。 + +`packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts` 在一个不含任何面向模型行的宿主组装上,通过 `startInProcessRun()` 断言模型可见的结果:子 agent 自身请求中的 schema、父方的提示段、记录下来的 header preset、施加在继承来的 preset 工具之上的 `toolFilter`,以及在空白期切换过 preset 的父方——切换到**另一个** preset,这样断言才能区分"读父方活 scope 链"与"读父方创建 header"。 组装记录这一层用的是真实 shipped Web 组装的 e2e,而不是无密钥快照。本仓库所有可运行 example 都不组装 preset roster,因此该缺陷在快照 harness 里根本不可观察:要做快照场景,得先有一个既挂载 roster 又发起委派的 example。Web e2e 启动的是真实的 `base` + `web-app` 补丁层与两个 shipped preset,这正是测试政策要求的组装证据;Web 浏览器 lane 的 subagent golden 承载了可见后果——记录了 preset 的子 agent 现在会显示与其父方相同的 preset 徽标。 ## Consequences -委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力——逐子 agent 的 `toolFilter` 并不能收窄它,原因见下方另行跟踪的那条;逐 subagent 的 preset("agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。 +委派现在的成本是每个子 agent 一次 scope 认父,再无其他——没有额外的插件实例、没有 roster 读取、没有新的失败模式。子 agent 的能力恰好等于父方的能力,减去它自己的 `toolFilter` 所移除的部分;逐 subagent 的 preset("agent 类型")仍未构建,那会是一个新的请求字段,而不是对这次加入的改动。 `applyChildComposition()` 的形态变了,因此将来任何仓库外的进程内驱动都必须提供父方。这是刻意付出的代价:此前的签名允许调用方组装出一个毫无能力的子 agent 而不报任何错。 冷恢复的可继续子 agent 加入的是父方**当前**的组装,而不是它自己 header 所记录的那份。窗口很窄——父方必须先建子、保持空白、切换 preset,之后才唤醒它;驻留中的子 agent 不会重新加入,一次性子 agent 也不会恢复——而替代方案更糟:按子 agent 自己记录的 id 解析会重读 roster,把这次认父刻意规避掉的"preset 已删除"失败模式又请回来。子 agent 的 header 仍记录它启动时的那份,因此这处分歧是可观察的而非静默的。 -`toolFilter` 约束不住已加入组装的子 agent,因为 `ToolRegistry` 只按全局层的名字编译限制,随后把 scope 链上的工具无过滤地叠加进来。这不是本次改动带来的——在组装了 roster 的部署里,`tools.restrict()` 本就把每个名字都判为未知全局工具,因此带过滤器的子 agent 在本次改动前后同样起不来——但它是搬到 agent 平面所引入的回归,而非长期存在的限制:同样这批工具注册在全局层时,过滤器能正常校验并生效。现在子 agent 有了父方的全套工具需要被限制,它变得更要紧。该问题另行跟踪;本次改动既未引入也未修复它。 +`ToolRegistry` 现在把限制的豁免集合读作"该作用域自己注册的东西"而不是"全局层",这在委派之外改变了一处既有行为:**祖先**作用域贡献的工具现在会受后代过滤器约束,而此前只有全局层的工具会。链上其余部分的豁免不变——作用域自身的注册仍在自己的过滤器之外,这正是委派运行时所依赖的性质。 diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 3f7e33a795..fbf617f20a 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tools.md -tools.md: f8d86704a2237219530c8c23b46a68383458e1cf -tools.zh.md: 87269e5532b0cdfb0a38501d7b98c9986665df1d +tools.md: 6ff2d967c5631d096dd78236ffd0383ddb2b0493 +tools.zh.md: 82ade5d8d4117387138296cf54fbc8e88ad335e7 diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index f8d86704a2..6ff2d967c5 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -150,19 +150,20 @@ type InferArgs<S> = InferProperties<S, []> Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input, requires `output`, validates its raw schema, and checks semantic requirements such as a positive finite `timeoutMs`; `schemas()` constructs the model-facing projection when building a request, so execution and presentation share one resolved definition without leaking callbacks onto the wire. -## `ToolRestriction` — one scope's live global filter +## `ToolRestriction` — one scope's live filter over what it inherits -`ToolRestriction` applies only to the live deployment-global tool layer. The registry compiles readonly names into private sets, intersects multiple restrictions, then overlays scope-local tools. A deny-only filter admits later unlisted globals, while an allow-list excludes them. +`ToolRestriction` applies to the tools a scope inherits: the deployment-global layer plus every ancestor scope on its chain. The registry compiles readonly names into private sets, intersects multiple restrictions, then overlays the scope's OWN registrations, which stay exempt so a delegated child keeps the tools it answers through. A deny-only filter admits later unlisted inherited tools, while an allow-list excludes them. ```ts type-equiv /** - * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved Code Mode transport. + * Per-scope filter over the tools a scope INHERITS — the global layer and + * every ancestor layer on its chain. Restrictions intersect, and do not affect + * the scope's own registrations or the reserved Code Mode transport. */ interface ToolRestriction { - /** Global tool names that stay visible; everything else is removed. */ + /** Inherited tool names that stay visible; every other inherited one is removed. */ readonly allow?: readonly string[] - /** Global tool names removed from visibility. */ + /** Inherited tool names removed from visibility. */ readonly deny?: readonly string[] } ``` @@ -565,7 +566,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:760`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:761`](../../packages/core/tools/src/index.ts) <a id="tools-events"></a> diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 87269e5532..82ade5d8d4 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -150,19 +150,20 @@ type InferArgs<S> = InferProperties<S, []> 注册是一项受信任的同进程约定。注册表以 readonly 输入借用已类型化定义,要求它声明 `output`,校验其原始 schema,并检查 `timeoutMs` 必须为正有限值等语义要求;`schemas()` 在构建请求时生成面向模型的投影,使执行和展示共享同一份已解析定义,而不会将回调泄漏到协议上。 -## `ToolRestriction` — 单个作用域的实时全局过滤器 +## `ToolRestriction` — 单个作用域对其继承内容的实时过滤器 -`ToolRestriction` 仅作用于实时的部署全局工具层。注册表将 readonly 名称编译为私有集合,对多个限制取交集,再叠加作用域本地工具。仅 deny 的过滤器允许后续未列出的全局工具通过,而 allow 列表则排除它们。 +`ToolRestriction` 作用于该作用域继承来的工具:部署全局层,加上其链上的每个祖先作用域。注册表将 readonly 名称编译为私有集合,对多个限制取交集,再叠加该作用域**自身**的注册——后者不受约束,因此被委派的子 agent 会保留其回报所依赖的工具。仅 deny 的过滤器允许后续未列出的继承工具通过,而 allow 列表则排除它们。 ```ts type-equiv /** - * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved Code Mode transport. + * Per-scope filter over the tools a scope INHERITS — the global layer and + * every ancestor layer on its chain. Restrictions intersect, and do not affect + * the scope's own registrations or the reserved Code Mode transport. */ interface ToolRestriction { - /** Global tool names that stay visible; everything else is removed. */ + /** Inherited tool names that stay visible; every other inherited one is removed. */ readonly allow?: readonly string[] - /** Global tool names removed from visibility. */ + /** Inherited tool names removed from visibility. */ readonly deny?: readonly string[] } ``` @@ -565,7 +566,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:760`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:761`](../../packages/core/tools/src/index.ts) <a id="tools-events"></a> diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 0c5e1fee44..e3a9b36a95 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: 2c9833c3505c765283559590c8bc28b3c2077e2e -README.zh.md: d7766b432c5a319d214da80e3df438489519be92 +README.md: 21851ca887147364c76612bae2e6a00ebdccec39 +README.zh.md: aec3b434e52f473001505bbea5212d5e247eb46f diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 2c9833c350..21851ca887 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -19,7 +19,7 @@ tools: - `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber. - `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber. -- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). +- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to the tools that scope INHERITS — the global layer and every ancestor scope on its chain — and throws from a plain context. The scope's OWN registrations are exempt and merge afterwards, which is what keeps a delegated child's reporting and structured-output tools alive under a filter naming only the capabilities it may use. The filter is snapshotted at registration; multiple masks intersect, and a mask on an ancestor reaches every scope nested inside it. Deny masks admit later unnamed inherited tools, while allow masks exclude later names. Unknown, own-layer, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index d7766b432c..aec3b434e5 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -19,7 +19,7 @@ tools: - `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。 - `ctx.tools.presentAs(mode: ToolPresentationMode): () => void`:为本 agent 选择面向模型的呈现方式,仅对该 agent 遮蔽 `mode` 配置;从普通上下文调用会抛出(进程级呈现方式是那个配置字段),同一 scope 内第二次声明也会抛出。code 类模式还会为该 agent 注册它自己的 `tools:sdk` 段。清单本身不变——`schemas(agent)` 报告的仍是该 agent 的能力,坍缩的只是 assembly 里的工具。随调用方 fiber 一同释放。 -- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 +- `ctx.tools.restrict(filter)`:对该作用域**继承来的**工具——全局层以及其链上的每个祖先作用域——应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。作用域**自身**的注册不受掩码约束,并在其后合并进来,这正是让被委派子 agent 的回报与结构化输出工具能在只点名其可用能力的筛选器下存活的机制。筛选器在注册时创建快照;多个掩码取交集,祖先上的掩码作用于其内嵌套的每个作用域。拒绝掩码会接纳后来出现且未点名的继承工具,而允许掩码会排除后来出现的名称。未知、自身层或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。 - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。 - `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index a1653e7d16..86c69d9307 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -647,13 +647,14 @@ export interface Config { } /** - * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved Code Mode transport. + * Per-scope filter over the tools a scope INHERITS — the global layer and + * every ancestor layer on its chain. Restrictions intersect, and do not affect + * the scope's own registrations or the reserved Code Mode transport. */ export interface ToolRestriction { - /** Global tool names that stay visible; everything else is removed. */ + /** Inherited tool names that stay visible; every other inherited one is removed. */ readonly allow?: readonly string[] - /** Global tool names removed from visibility. */ + /** Inherited tool names removed from visibility. */ readonly deny?: readonly string[] } @@ -669,7 +670,7 @@ interface ToolView { readonly visible: ReadonlyMap<string, ToolDefinition> /** Pre-restriction capability names used by prompt-order validation. */ readonly knownNames: ReadonlySet<string> - /** Current global names that a scoped restriction may name. */ + /** Current inherited names a scoped restriction may name; its own are exempt. */ readonly restrictableNames: ReadonlySet<string> } @@ -707,7 +708,7 @@ class ToolLayer implements ScopeLayer { && this.mode === undefined } - /** Whether every compiled restriction in this layer admits a global tool name. */ + /** Whether every compiled restriction in this layer admits an inherited tool name. */ admits(name: string): boolean { for (const filter of this.restrictions.values()) { if ((filter.allow !== undefined && !filter.allow.has(name)) @@ -1029,7 +1030,7 @@ export class ToolRegistry extends Service { const known = this.view(scope).restrictableNames const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name)) if (unknown.length > 0) { - throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`) + throw new Error(`tools.restrict() names unknown inherited tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; a restriction filters what this scope inherits, never what it registers itself. Restrictable tools: ${[...known].sort().join(', ') || '(none)'}`) } return this.layers.effect( this.ctx, @@ -1070,30 +1071,54 @@ export class ToolRegistry extends Service { /** * Resolve every registry fact one scope needs in one layer traversal. The - * visible map applies global restrictions, scoped shadowing, and the reserved - * presentation transport; the other sets retain the pre-restriction facts - * needed by restriction and prompt-order validation. + * visible map applies restrictions to the INHERITED surface, then the + * scope's own registrations and the reserved presentation transport; the + * other sets retain the pre-restriction facts needed by restriction and + * prompt-order validation. + * + * A restriction filters what a scope inherits — the global layer and every + * ancestor layer on its chain — and never what its OWN layer registers. + * That exemption is what a per-child capability filter has to keep intact: + * the delegation runtime registers a child's reporting and structured-output + * tools into the child's own layer, and a filter naming the capabilities the + * child may use must not strip the machinery it answers through. + * + * Reading the exempt set as "the global layer" instead of "not mine" held + * only while every model-facing tool sat in the host composition. Once + * presets moved them onto the agent plane they became an ANCESTOR + * contribution, so a child's filter silently stopped constraining anything + * it was given. * @param scope - the viewing scope (the agent), or undefined for the global view. * @returns the complete derived view for that scope. */ private view(scope?: ScopeKey): ToolView { // Scope-chain layers, farthest ancestor first, the exact scope last. const layers = this.layers.chainLayers(scope) + // Chain-blind on purpose: this is the ONE layer whose registrations the + // scope owns rather than inherits, and it is absent until the scope + // contributes something. + const own = this.layers.peek(scope) + // Inherited surface, nearest ancestor last: a nearer scope's same-name + // entry shadows a farther one, and the global layer is the farthest. + const inherited = new Map<string, ToolDefinition>(this.layers.global.tools.entries()) + for (const layer of layers) { + if (layer === own) continue + for (const [name, definition] of layer.tools.entries()) inherited.set(name, definition) + } const visible = new Map<string, ToolDefinition>() const knownNames = new Set<string>() const restrictableNames = new Set<string>() - for (const [name, definition] of this.layers.global.tools.entries()) { + for (const [name, definition] of inherited) { knownNames.add(name) restrictableNames.add(name) // Restrictions intersect across the whole chain: any scope on it may - // mask a global-surface name for everything nested inside it. + // mask an inherited name for everything nested inside it. if (layers.every(layer => layer.admits(name))) visible.set(name, definition) } - // Chain layers second, nearest last: same-name entries REPLACE (shadow) - // the global and farther-scope ones, and scope-local registrations are - // never part of the global filter above. - for (const layer of layers) { - for (const [name, definition] of layer.tools.entries()) { + // The scope's own registrations last, shadowing an inherited name and + // outside the filter above. + if (own !== undefined) { + for (const [name, definition] of own.tools.entries()) { knownNames.add(name) visible.set(name, definition) } diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 922173653f..8f7be45b19 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import type { Events } from 'cordis' -import { createScope } from '@deepseek-ai/dsh-scope' +import { bindScopeParent, createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -181,21 +181,84 @@ describe('restrict()', () => { expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b']) }) - it('fails loud on an unscoped call, an empty filter, and non-global names', async () => { + it('fails loud on an unscoped call, an empty filter, and names it does not inherit', async () => { const ctx = await mount() const { scope } = await mintAgentScope(ctx, 'a') ctx.tools.register(tool('real')) scope.ctx.tools.register(tool('local')) expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/) expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/) - expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown global tool "local"/) - expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall"; known global tools: real/) - expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/) + // A scope's own registration is exempt from its own filter, so naming it + // is a caller error rather than a silent no-op. + expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown inherited tool "local"/) + expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown inherited tool "reall".*Restrictable tools: real/s) + expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown inherited tools "ghost", "wraith"/) const emptyCtx = await mount() const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty') expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] })) - .toThrow(/known global tools: \(none\)/) + .toThrow(/Restrictable tools: \(none\)/) + }) +}) + +describe('restrict() over an inherited scope layer', () => { + /** Mint a child scope parented to `parent`, as a subagent's creation window does. */ + async function mintChild(ctx: Context, parentKey: Agent, name: string): Promise<{ scope: Scope; key: Agent }> { + const key = { id: name as SessionId } as Agent + bindScopeParent(key, parentKey) + let scope!: Scope + await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, key) }, + { inject: ['tools', 'systemPrompt'] })) + return { scope, key } + } + + it('filters tools the child inherits from an ancestor scope, not only global ones', async () => { + // The shape every preset deployment has: no model-facing row in the global + // layer, all of them contributed by an ancestor scope the child joined. + const ctx = await mount() + const parent = await mintAgentScope(ctx, 'parent') + parent.scope.ctx.tools.register(tool('bash')) + parent.scope.ctx.tools.register(tool('read')) + const child = await mintChild(ctx, parent.key, 'child') + + expect(ctx.tools.schemas(child.key).map(t => t.name).sort()).toEqual(['bash', 'read']) + child.scope.ctx.tools.restrict({ deny: ['bash'] }) + + // Reading the exempt set as "the global layer" left this unfiltered, and + // the name unrestrictable in the first place. + expect(ctx.tools.schemas(child.key).map(t => t.name)).toEqual(['read']) + expect(await run(ctx, 'bash', child.key)).toBe('Error: unknown tool "bash"') + // The ancestor keeps its whole surface: a child's filter is its own. + expect(ctx.tools.schemas(parent.key).map(t => t.name).sort()).toEqual(['bash', 'read']) + }) + + it('keeps the child\'s own registrations outside its own filter', async () => { + // The delegation runtime registers a child's reporting and structured + // output tools into the child's own layer; an `allow` naming only the + // capabilities the child may use must not strip them. + const ctx = await mount() + const parent = await mintAgentScope(ctx, 'parent') + parent.scope.ctx.tools.register(tool('bash')) + parent.scope.ctx.tools.register(tool('read')) + const child = await mintChild(ctx, parent.key, 'child') + child.scope.ctx.tools.register(tool('report')) + + child.scope.ctx.tools.restrict({ allow: ['read'] }) + + expect(ctx.tools.schemas(child.key).map(t => t.name).sort()).toEqual(['read', 'report']) + expect(await run(ctx, 'report', child.key)).toBe('ran:report') + }) + + it('lets an ancestor\'s restriction reach every scope nested inside it', async () => { + const ctx = await mount() + ctx.tools.register(tool('web')) + const parent = await mintAgentScope(ctx, 'parent') + parent.scope.ctx.tools.register(tool('bash')) + const child = await mintChild(ctx, parent.key, 'child') + parent.scope.ctx.tools.restrict({ deny: ['web'] }) + + expect(ctx.tools.schemas(child.key).map(t => t.name)).toEqual(['bash']) + expect(ctx.tools.schemas(parent.key).map(t => t.name)).toEqual(['bash']) }) }) diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts index 43061d46db..af199cd867 100644 --- a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -103,6 +103,21 @@ describe('a child agent composed in-process', () => { await run.dispose() }) + it('honours a tool filter over the preset tools it inherited', async () => { + const { ctx, parent } = await setupPresetHost() + + const run = await startInProcessRun( + { ...spawnRequest(parent), toolFilter: { deny: ['preset_only'] } }, + {}, + ) + await run.result + + // The capability filter is the only thing bounding a delegated child, and + // every tool it can name now arrives from the preset rather than the host. + expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual([]) + await run.dispose() + }) + it('follows a parent that switched preset while blank', async () => { const { ctx, parent } = await setupPresetHost() // A DIFFERENT preset, so the assertion below distinguishes reading the diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index d175322381..46b94f9f5a 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -298,7 +298,7 @@ describe('startInProcessRun', () => { await expect(startInProcessRun({ ...request(parent), toolFilter: { deny: ['unknown-tool'] }, - }, {})).rejects.toThrow('unknown global tool') + }, {})).rejects.toThrow('unknown inherited tool') expect(ctx.agents.list()).toHaveLength(beforeAgents) expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 0310c0b0b3..508d5132fb 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -436,7 +436,7 @@ describe('dsh-subagent-spawn', () => { prompt: [{ type: 'text', text: 'do X' }], parent, toolFilter: { deny: ['no_such_tool'] }, - })).rejects.toThrow(/unknown global tool "no_such_tool"/) + })).rejects.toThrow(/unknown inherited tool "no_such_tool"/) expect(ctx.agents.list().length).toBe(before) }) }) From 44816376847aed2a57b79f544a14f28b84ab6216 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 20:55:02 +0800 Subject: [PATCH 122/229] fix(python): package the minimal runtime closure --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 6 +- ...-executable-sdk-runtime-distribution.zh.md | 6 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 6 +- packages/boot/app-boot/README.zh.md | 6 +- packages/boot/app-boot/src/index.ts | 49 +- packages/boot/app-boot/tests/app-boot.spec.ts | 47 + packages/examples/jsonrpc-demo/src/bin.ts | 4 +- .../sandbox/sandbox-windows-acl/package.json | 1 + pnpm-lock.yaml | 3 + pnpm-workspace.yaml | 2 +- python/sdk-runtime/package.json | 1 + scripts/build-exe-for-python-sdk.ts | 49 +- scripts/check-workspace-constraints.ts | 5 +- scripts/smoke-python-runtime.py | 179 ++- .../advanced/result.json | 1016 +++++++++++------ .../advanced/session.1.jsonl | 32 +- .../advanced/session.2.jsonl | 32 +- .../advanced/session.jsonl | 135 +-- 20 files changed, 1008 insertions(+), 579 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index c1ab35fa0b..ceb0eae37a 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: fd232e8893b7beebe2e279cb5532daf8ef73a8a3 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: bb0b6f8f660a42495da651a581236a7ce2a50773 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 826194e0d5bd1f0260400c036f8affaf1549629f +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: e4b17a1f3951f36af88564d5365ab7952d6281a5 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index fd232e8893..826194e0d5 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -34,13 +34,13 @@ Config discovery has two channels and fails loudly when both are missing: the `D ### Plugin resolution: the VFS holds a real package tree, the closure manifest IS the deploy root -Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`); the Loader resolves plugin names through standard dynamic `import()`: bare specifiers resolve upward along `node_modules` from the Loader's position inside the VFS, and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. +Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The JSON-RPC bin supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. Bare specifiers resolve upward along `node_modules` from the Loader's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local symlink tree and rejecting any remaining manifest gap → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink package payload (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. @@ -62,7 +62,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index bb0b6f8f66..e4b17a1f39 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -34,13 +34,13 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 ### 插件解析:VFS 装载真实包树,闭包 manifest(元数据清单)就是部署根目录 -exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。loader 通过标准动态 `import()` 解析插件名:裸包名从 VFS 内 loader 所在位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 +exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。JSON-RPC bin 会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。裸包名从 VFS 内 loader 所在位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 ### 构建管线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内符号链接树,并拒绝剩余的 manifest 缺口 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的包载荷(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR(Pull Request)添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 @@ -62,7 +62,7 @@ exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个 ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,短命管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index cec63092de..a55e250b6d 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: be03bceb39935fafb7acc7d3a99c1fe3af686f94 -README.zh.md: 10165486712fc078cdf1f4147522397a15c88955 +README.md: f3ffdae3846edba6f1a1a4821adade7b6c7fce76 +README.zh.md: 4f31fd743f1ddc57edc9c215a42e79a16afcdecb diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index be03bceb39..f3ffdae384 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -15,10 +15,10 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`ds | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required top-level YAML array containing the same include `PatchOptions` entries described above; a missing file also throws because the caller named it | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR | +| `mountRootInclude(ctx, absoluteConfigPath, patches?, bareModuleBaseUrl?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR; an optional module base anchors bare package names to the installed host while relative names stay config-relative | | `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error | +| `boot(binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error; the optional module base has the same resolution semantics as `mountRootInclude` | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline with the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts, and render YAML with `!!js` expressions verbatim; each run of rows that shares one source file and the same patch layers is preceded by a `# ==` comment naming that file and those layers, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), and read, parse, or field validation failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | @@ -29,7 +29,7 @@ The Loader mounts entries concurrently, so a surface can already own the termina `cordis:group` is registered beside `cordis:include` so a composition can give one `isolate` realm to a provider and its consumers together. Both load through the ambient module pipeline rather than the included tree's own specifier resolution, which is what lets a composition outside this workspace — an agent preset under the Harness home — use a group row at all. -Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. +Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. They resolve from the config directory by default; a closed runtime passes `bareModuleBaseUrl` to `boot` or `mountRootInclude` so its installed package tree remains authoritative even when the config lives inside another Node project. Relative specifiers always resolve against the config directory. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 1016548671..4f31fd743f 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -15,10 +15,10 @@ | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析必需的顶层 YAML 数组,其中包含与上文相同的 include `PatchOptions` 条目;文件缺失也会抛出异常,因为该文件是调用方指名的 | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 | +| `mountRootInclude(ctx, absoluteConfigPath, patches?, bareModuleBaseUrl?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项;可选模块基准会把裸包名锚定到已安装宿主,而相对名称仍以配置目录为基准 | | `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步清理函数 | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | +| `boot(binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject;可选模块基准与 `mountRootInclude` 的解析语义相同 | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`)离线合成基础配置与带标签的覆盖层,使结果与 `boot()` 挂载的内容一致,再渲染为 YAML,并原样保留 `!!js` 表达式;每段来源于同一文件且由相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取、解析或字段验证失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | @@ -29,7 +29,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 `cordis:group` 与 `cordis:include` 一并注册,使一份组装能把一个提供方与它的消费方放进同一个 `isolate` realm。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析,这正是让本工作区之外的组装——放在 Harness home 下的 agent preset——能够使用 group 行的原因。 -配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 +配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。默认情况下,它们从配置目录解析;封闭运行时会向 `boot` 或 `mountRootInclude` 传入 `bareModuleBaseUrl`,使已安装包树保持权威,即使配置位于另一个 Node 项目中也不受遮蔽。相对 specifier 始终以配置目录为基准解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index fa23e6f8da..40e80ce504 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -9,7 +9,7 @@ import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' import { parseEnv } from 'node:util' -import { basename, dirname, resolve } from 'node:path' +import { basename, dirname, isAbsolute, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' @@ -476,6 +476,8 @@ function groupedDump( * @param ctx - context carrying an initialized Loader service. * @param absoluteConfigPath - absolute YAML or JSON configuration path. * @param patches - initial app and user patches, applied in order. + * @param bareModuleBaseUrl - optional installed-host base for bare package + * names; relative names continue to resolve beside the configuration file. * @returns the created root Include entry, or `undefined` when a surface * disposed the whole tree (taking the Loader service with it) while the * transactional create was still settling entry lifecycle. @@ -484,8 +486,21 @@ export async function mountRootInclude( ctx: Context, absoluteConfigPath: string, patches: readonly PatchOptions[] = [], + bareModuleBaseUrl?: string, ): Promise<Entry | undefined> { - ctx.loader.builtins.include = Include + ctx.loader.builtins.include = bareModuleBaseUrl === undefined + ? Include + : class HostResolvedRootInclude extends Include { + override import(name: string, getOuterStack?: () => string[]): unknown { + const specifier = isAbsolute(name) ? pathToFileURL(name).href : name + if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(specifier, getOuterStack) + const internal = this.ctx.loader.internal + /* v8 ignore next -- Node supplies the internal loader; this preserves the + original diagnostic for hypothetical embedders without it. */ + if (internal === undefined) return super.import(specifier, getOuterStack) + return internal.import(specifier, bareModuleBaseUrl, {}) + } + } // `cordis:group` alongside it: a group row is how a composition gives one // `isolate` realm to a provider and its consumers together, and an agent // preset living outside this workspace cannot resolve `@cordisjs/plugin-group` @@ -495,13 +510,14 @@ export async function mountRootInclude( // Pinned id: the bootstrap include is app glue, not a config row, and its // id appears in Loader failure chains — a random id would make startup // diagnostics unstable across runs (and snapshot fixtures). + const includeConfig: Include.Config = { + path: pathToFileURL(absoluteConfigPath).href, + ...patches.length > 0 ? { patches: [...patches] } : {}, + } const rootInclude: EntryOptions = { id: 'include', name: 'cordis:include', - config: { - path: pathToFileURL(absoluteConfigPath).href, - ...patches.length > 0 ? { patches: [...patches] } : {}, - }, + config: includeConfig, } const includeId = await ctx.loader.create(rootInclude) const loader = ctx.get('loader') @@ -709,14 +725,13 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro /** * Boot the Loader against `absoluteConfigPath` and return only after the whole - * tree settles. Entry names load through the Loader's internal module loader - * against `baseUrl` (the config directory), which may live outside - * `node_modules` reach and, unbuilt, cannot load vendored source; the - * bootstrap include is therefore statically imported and mounted as the - * `cordis:include` builtin, loading through the ambient module pipeline - * (vite/tsx/plain ESM) while the included tree's own specifiers stay - * config-relative. The package build embeds Include while leaving Loader - * external, so the built include tree and host share one Loader peer. Loader + * tree settles. Relative entry names resolve against the config directory; + * bare package names resolve there by default or against an explicit + * `bareModuleBaseUrl` for closed packaged runtimes. The bootstrap include + * is statically imported and mounted as the `cordis:include` builtin, loading + * through the ambient module pipeline (vite/tsx/plain ESM). The package build + * embeds Include while leaving Loader external, so the built include tree and + * host share one Loader peer. Loader * settlement rejects startup failures, which `boot` wraps after disposing the * partial context; a missing fiber or never-activating entry is rejected by * the final audit, {@link assertEntriesActivated}, which rethrows a plugin's @@ -729,6 +744,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * @param patches - optional overlay patches applied over the included tree * (see {@link loadOptionalPatches}); an empty list mounts none. * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts. + * @param bareModuleBaseUrl - optional installed-host base for bare package + * names; use it when the host, rather than the configuration project, owns the + * complete plugin set. * @returns the root context once every entry has started, or as soon as a * surface disposed the tree while startup was still in flight. * @throws a labelled error after disposing the partial context — `host @@ -740,6 +758,7 @@ export async function boot( absoluteConfigPath: string, patches?: PatchOptions[], prepare?: (ctx: Context) => Promise<void> | void, + bareModuleBaseUrl?: string, ): Promise<Context> { const ctx = new Context() // Two failure labels: `prepare` runs before any config-tree entry mounts, @@ -751,7 +770,7 @@ export async function boot( await ctx.plugin(Loader) await prepare?.(ctx) stage = 'plugin tree failed to load' - await mountRootInclude(ctx, absoluteConfigPath, patches) + await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl) // A surface can finish and dispose the whole tree while startup is still // in flight, before the last entry settles. The Loader service goes with // it, and the activation audit describes a live tree — reading `ctx.loader` diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index baeb98fe77..ab0089fd2c 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -557,6 +557,53 @@ describe('boot', () => { } }) + it('can resolve bare plugins from the harness when the config project shadows their package name', async () => { + const dir = tmp() + const absolutePlugin = join(dir, 'absolute.mjs') + const shadow = join(dir, 'node_modules', '@deepseek-ai', 'dsh-system-prompt') + mkdirSync(shadow, { recursive: true }) + writeFileSync(join(shadow, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh-system-prompt', + type: 'module', + exports: './index.mjs', + })) + writeFileSync(join(shadow, 'index.mjs'), [ + 'export function apply(ctx) {', + ' ctx.provide("shadowPluginLoaded", true)', + '}', + '', + ].join('\n')) + writeFileSync(join(dir, 'relative.mjs'), 'export function apply(ctx) { ctx.provide("relativePluginLoaded", true) }\n') + writeFileSync(absolutePlugin, 'export function apply(ctx) { ctx.provide("absolutePluginLoaded", true) }\n') + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: prompt', + " name: '@deepseek-ai/dsh-system-prompt'", + '- id: relative', + " name: './relative.mjs'", + '- id: absolute', + ` name: ${JSON.stringify(absolutePlugin)}`, + '', + ].join('\n')) + const configOwned = await boot(NAME, join(dir, 'cordis.yml')) + try { + expect(configOwned.get('shadowPluginLoaded')).toBe(true) + expect(configOwned.get('systemPrompt')).toBeUndefined() + expect(configOwned.get('relativePluginLoaded')).toBe(true) + expect(configOwned.get('absolutePluginLoaded')).toBe(true) + } finally { + await configOwned.fiber.dispose() + } + const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, undefined, import.meta.url) + try { + expect(ctx.get('systemPrompt')).toBeDefined() + expect(ctx.get('shadowPluginLoaded')).toBeUndefined() + expect(ctx.get('relativePluginLoaded')).toBe(true) + expect(ctx.get('absolutePluginLoaded')).toBe(true) + } finally { + await ctx.fiber.dispose() + } + }) + it('runs host preparation before the Loader tree mounts', async () => { const dir = tmp() writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n') diff --git a/packages/examples/jsonrpc-demo/src/bin.ts b/packages/examples/jsonrpc-demo/src/bin.ts index ad17709efc..cecc93dac4 100644 --- a/packages/examples/jsonrpc-demo/src/bin.ts +++ b/packages/examples/jsonrpc-demo/src/bin.ts @@ -33,7 +33,9 @@ if (configPath === undefined || !existsSync(configPath)) { process.exit(1) } -const ctx = await boot(NAME, configPath) +// The executable owns a closed plugin set; config-adjacent node_modules must +// not shadow the packages embedded beside this bin in the VFS. +const ctx = await boot(NAME, configPath, undefined, undefined, import.meta.url) let exiting = false async function disposeAndExit(code: number): Promise<void> { diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 2f13b71296..b5e66b1f33 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -26,6 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/runner.js", + "lib/types-*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ff65ff8cf..b58ed88a9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7642,6 +7642,9 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../packages/fs/fs-sandbox '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../packages/goal/goal diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 66510d89ec..2541e949b8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -48,7 +48,7 @@ allowBuilds: koffi: true # The Python runtime deploy includes the reviewed workspace postinstall that # restores the executable bit on node-pty's macOS spawn helper. - '@deepseek-ai/dsh-pty-local@file:packages/pty/pty-local': true + '@deepseek-ai/dsh-subprocess-local@file:packages/subprocess/subprocess-local': true minimumReleaseAgeExclude: # Cordis release candidates are source-vendored and pinned in vendor/README.md diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index d5e90fb1b9..0ff390dd33 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 536342dc89..5f525ec358 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -8,7 +8,7 @@ import { spawn } from 'node:child_process' import { existsSync, statSync } from 'node:fs' -import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -28,6 +28,8 @@ const OUT_DIR = 'dist-exe' const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime' /** The deployed closure doubles as the node-mode carrier. */ const PYTHON_NODE_SUBDIR = 'node' +/** Legacy deploy may hoist peer-specialized workspace packages back here. */ +const DEPLOY_SOURCE_NODE_MODULES = 'python/sdk-runtime/node_modules' /** Documentation excluded from the generated runtime directory. */ const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml'] @@ -256,6 +258,7 @@ class SingleExeBuild { '--config.link-workspace-packages=true', this.staging, ]) + await this.restoreLegacyHoists() if (this.cli.dryRun) { for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`) } else { @@ -263,6 +266,50 @@ class SingleExeBuild { } } + /** + * Restore direct packages that pnpm's legacy hoister places beside the deploy + * source instead of in the target. The runtime manifest supplies every peer, + * so package-local node_modules trees are omitted to preserve one flat Cordis + * instance and a symlink-free packaged payload. + */ + private async restoreLegacyHoists(): Promise<void> { + if (this.cli.dryRun) { + console.log('build-exe-for-python-sdk: [dry-run] restore direct dependencies omitted by legacy deploy') + return + } + const manifestPath = join(this.staging, 'package.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { + dependencies?: Record<string, string> + } + const sourceNodeModules = resolve(root, DEPLOY_SOURCE_NODE_MODULES) + const restored: string[] = [] + for (const dependency of Object.keys(manifest.dependencies ?? {}).sort()) { + const destination = join(this.staging, 'node_modules', dependency) + if (existsSync(destination)) continue + const source = join(sourceNodeModules, dependency) + if (!existsSync(source)) { + throw new Error( + `build-exe-for-python-sdk: deployed dependency ${dependency} is absent from both ${destination} and ${source}.`, + ) + } + await mkdir(dirname(destination), { recursive: true }) + const nestedNodeModules = join(source, 'node_modules') + await cp(source, destination, { + recursive: true, + filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep), + }) + restored.push(dependency) + } + const stillMissing = Object.keys(manifest.dependencies ?? {}) + .filter(dependency => !existsSync(join(this.staging, 'node_modules', dependency))) + if (stillMissing.length > 0) { + throw new Error(`build-exe-for-python-sdk: staged dependencies remain missing: ${stillMissing.join(', ')}.`) + } + if (restored.length > 0) { + console.log(`build-exe-for-python-sdk: restored legacy deploy hoists: ${restored.join(', ')}`) + } + } + /** Add the executable entry and pkg assets to the staged manifest. */ async injectPkgConfig(): Promise<void> { const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } } diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index c38197c1e3..5f6770b2e1 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -127,8 +127,9 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = { '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], // The argv-prefix runner entry ships beside the lib as its own bundle; - // sandbox-local resolves it through the package's ./runner export. - '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js'], + // sandbox-local resolves it through the package's ./runner export. tsdown + // also shares its generated FFI code through a hashed runtime chunk. + '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'], '@deepseek-ai/dsh-skill-badge': ['assets'], '@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'], '@deepseek-ai/dsh-scripts': [ diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 415784cd7a..910b4ffc0a 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Callable if TYPE_CHECKING: - from deepseek_harness import TurnResult + from deepseek_harness import RunResult EXPECTED_TEXT = "runtime smoke ok" @@ -25,10 +25,14 @@ CODE_PROMPT = "Use run_code to compute the packaged worker smoke value." CODE_WORKER_TEXT = "code worker smoke ok" WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents." WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" -PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor." -PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok" -PERSISTENT_EDITOR_PATH_PREFIX = "Editor path: " -PERSISTENT_BASH_COMMAND = ( +MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and string-replacement editor." +MINIMAL_TEXT = "minimal agent smoke ok" +MINIMAL_EDITOR_PATH_PREFIX = "Editor path: " +MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant." +MINIMAL_CORDIS = ( + Path(__file__).resolve().parent.parent / "examples" / "jsonrpc-agent" / "minimal.cordis.yml" +) +MINIMAL_BASH_COMMAND = ( "counter=$(( ${counter:-0} + 1 )); export counter; " "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi" @@ -103,51 +107,6 @@ CUSTOM_CORDIS = """\ - id: cordis-tool name: '@deepseek-ai/dsh-tool-cordis' """ -PERSISTENT_TOOLS_CORDIS = """\ -- id: jsonrpc - name: '@deepseek-ai/dsh-jsonrpc' -- id: llm - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: danger-full-access - workspaceRoot: !!js process.env.DSH_CWD -- id: pty - name: '@deepseek-ai/dsh-pty' -- id: pty-local - name: '@deepseek-ai/dsh-pty-local' -- id: fs - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.env.DSH_CWD -- id: agent-core - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - includeHarnessIdentity: false - persona: 'You are a helpful software engineer assistant.' - workspaceContext: false - skills: - enabled: false - toolBash: false - toolTasks: false -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT - compression: 'none' -- id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' -- id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' -""" - - class MockModelHandler(BaseHTTPRequestHandler): """Return deterministic text, worker, and orchestration completions.""" @@ -182,9 +141,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if latest.get("role") == "tool": call_id, tool_name = latest_tool_call(messages) tool_text = message_text(latest.get("content")) - persistent = persistent_tool_followup(body, call_id, tool_name, tool_text) - if persistent is not None: - return persistent + minimal = minimal_tool_followup(body, call_id, tool_name, tool_text) + if minimal is not None: + return minimal advanced = advanced_tool_followup(body, call_id, tool_name, tool_text) if advanced is not None: return advanced @@ -196,16 +155,35 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return text_chunks(WORKFLOW_WORKER_TEXT) raise AssertionError(f"unexpected tool follow-up: {tool_name}") - prompt = message_text(latest.get("content")) - if prompt.startswith(f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}"): + minimal_prompt = next( + ( + message_text(message.get("content")) + for message in reversed(messages) + if isinstance(message, dict) + and message.get("role") == "user" + and message_text(message.get("content")).startswith( + f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}" + ) + ), + None, + ) + if minimal_prompt is not None: names = advertised_tool_names(body) if names != {"bash", "str_replace_editor"}: - raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}") + raise AssertionError(f"minimal agent smoke advertised unexpected tools: {names}") + system_prompts = [ + message_text(message.get("content")) + for message in messages + if isinstance(message, dict) and message.get("role") == "system" + ] + if system_prompts != [MINIMAL_SYSTEM_PROMPT]: + raise AssertionError(f"minimal agent smoke assembled unexpected system prompts: {system_prompts}") return tool_call_chunks( - "persistent-bash-1", + "minimal-bash-1", "bash", - {"command": PERSISTENT_BASH_COMMAND}, + {"command": MINIMAL_BASH_COMMAND}, ) + prompt = message_text(latest.get("content")) if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT: return text_chunks("DIRECT_CHILD_OK") if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT: @@ -240,24 +218,24 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return text_chunks(EXPECTED_TEXT) -def persistent_tool_followup( +def minimal_tool_followup( body: dict[str, object], call_id: str, tool_name: str, tool_text: str, ) -> list[dict[str, object]] | None: - """Verify packaged PTY persistence, then invoke the packaged editor.""" - if not call_id.startswith("persistent-"): + """Verify the checked-in minimal composition's PTY and editor.""" + if not call_id.startswith("minimal-"): return None - if call_id == "persistent-bash-1" and tool_name == "bash": + if call_id == "minimal-bash-1" and tool_name == "bash": if "COUNT=1" not in tool_text: raise AssertionError(f"first persistent bash call lost its output: {tool_text}") return tool_call_chunks( - "persistent-bash-2", + "minimal-bash-2", "bash", - {"command": PERSISTENT_BASH_COMMAND}, + {"command": MINIMAL_BASH_COMMAND}, ) - if call_id == "persistent-bash-2" and tool_name == "bash": + if call_id == "minimal-bash-2" and tool_name == "bash": if "COUNT=2 CWD=/tmp" not in tool_text: raise AssertionError(f"persistent bash did not retain state: {tool_text}") messages = body.get("messages") @@ -265,18 +243,18 @@ def persistent_tool_followup( raise AssertionError("persistent editor smoke request has no messages") editor_path = next( ( - text.split(PERSISTENT_EDITOR_PATH_PREFIX, 1)[1].strip() + text.split(MINIMAL_EDITOR_PATH_PREFIX, 1)[1].strip() for message in messages if isinstance(message, dict) and message.get("role") == "user" for text in [message_text(message.get("content"))] - if PERSISTENT_EDITOR_PATH_PREFIX in text + if MINIMAL_EDITOR_PATH_PREFIX in text ), None, ) if editor_path is None: raise AssertionError("persistent editor smoke prompt has no editor path") return tool_call_chunks( - "persistent-editor", + "minimal-editor", "str_replace_editor", { "command": "create", @@ -284,11 +262,11 @@ def persistent_tool_followup( "file_text": "created by packaged editor\n", }, ) - if call_id == "persistent-editor" and tool_name == "str_replace_editor": + if call_id == "minimal-editor" and tool_name == "str_replace_editor": if "New file created successfully" not in tool_text: raise AssertionError(f"packaged editor did not create its file: {tool_text}") - return text_chunks(PERSISTENT_TOOLS_TEXT) - raise AssertionError(f"unexpected persistent-tools follow-up: {call_id} {tool_name}: {tool_text}") + return text_chunks(MINIMAL_TEXT) + raise AssertionError(f"unexpected minimal-agent follow-up: {call_id} {tool_name}: {tool_text}") def advanced_tool_followup( @@ -470,14 +448,14 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"), default="all", ) parser.add_argument("--exe", type=Path) parser.add_argument("--update-snapshots", action="store_true") args = parser.parse_args() - if args.scenario in {"all", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"} and args.exe is None: - parser.error("--exe is required for custom, persistent, snapshot, and direct scenarios") + if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"} and args.exe is None: + parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios") if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}: parser.error("--update-snapshots requires --scenario sdk-snapshot or all") if args.exe is not None and not args.exe.is_file(): @@ -489,9 +467,9 @@ def main() -> None: if args.scenario in {"all", "sdk-custom"}: assert args.exe is not None smoke_sdk_custom(model.url, args.exe.resolve()) - if args.scenario in {"all", "sdk-persistent"}: + if args.scenario in {"all", "sdk-minimal"}: assert args.exe is not None - smoke_sdk_persistent_tools(model.url, args.exe.resolve()) + smoke_sdk_minimal(model.url, args.exe.resolve()) if args.scenario in {"all", "sdk-snapshot"}: assert args.exe is not None smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots) @@ -519,7 +497,6 @@ def smoke_sdk_default(base_url: str) -> None: request_timeout_seconds=60, ) as harness: result = harness.run("reply with the smoke text", session_id="default-smoke") - assert result.status == "ok", result assert result.final_response == EXPECTED_TEXT, result.final_response assert_zstd_session_log(sessions) @@ -546,46 +523,40 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: text_result = harness.run("reply with the smoke text", session_id="custom-smoke") code_result = harness.run(CODE_PROMPT, session_id="custom-smoke") workflow_result = harness.run(WORKFLOW_PROMPT, session_id="custom-smoke") - assert text_result.status == "ok", text_result assert text_result.final_response == EXPECTED_TEXT, text_result.final_response - assert code_result.status == "ok", code_result assert code_result.final_response == CODE_WORKER_TEXT, code_result.final_response - assert workflow_result.status == "ok", workflow_result assert workflow_result.final_response == WORKFLOW_WORKER_TEXT, workflow_result.final_response assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT) -def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None: - """Exercise native PTY state and the editor through the packaged executable.""" +def smoke_sdk_minimal(base_url: str, executable: Path) -> None: + """Exercise the checked-in minimal composition through the packaged executable.""" from deepseek_harness import DeepSeekHarness - with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary: + with tempfile.TemporaryDirectory(prefix="dsh-sdk-minimal-") as temporary: root = Path(temporary).resolve() editor_path = root / "created.txt" - prompt = f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}{editor_path}" + prompt = f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}{editor_path}" sessions = root / "sessions" - cordis = root / "cordis.yml" - cordis.write_text(PERSISTENT_TOOLS_CORDIS) with DeepSeekHarness( - provider="deepseek", + provider="deepseek-official", model="smoke-model", cwd=str(root), session_root=str(sessions), - cordis=str(cordis), + cordis=str(MINIMAL_CORDIS), runtime_bin=str(executable), api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, ) as harness: - result = harness.run(prompt, session_id="persistent-tools-smoke") + result = harness.run(prompt, session_id="minimal-agent-smoke") - assert result.status == "ok", result event_text = json.dumps(result.events) - if PERSISTENT_TOOLS_TEXT not in event_text: - raise AssertionError(f"packaged tools run emitted no final response: {result.events}") + if MINIMAL_TEXT not in event_text: + raise AssertionError(f"minimal agent run emitted no final response: {result.events}") if editor_path.read_text() != "created by packaged editor\n": raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}") - assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") + assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: @@ -610,7 +581,6 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) ) as harness: result = harness.run(SNAPSHOT_PROMPT, session_id=SNAPSHOT_SESSION_ID) - assert result.status == "ok", result assert result.final_response == SNAPSHOT_FINAL_TEXT, result.final_response methods = [notification.method for notification in result.notifications] if methods.count("subagent.started") != 2 or methods.count("subagent.finished") != 2: @@ -657,8 +627,8 @@ def smoke_direct(base_url: str, executable: Path) -> None: "params": {"sessionId": "direct-smoke", "contentBlocks": [{"type": "text", "text": "reply with the smoke text"}]}, }) messages = peer.read_until(lambda message: message.get("id") == "prompt") - if not any(message.get("method") == "session.finished" and message.get("params", {}).get("status") == "ok" for message in messages): - messages.extend(peer.read_until(lambda message: message.get("method") == "session.finished")) + if not any(is_idle_notification(message) for message in messages): + messages.extend(peer.read_until(is_idle_notification)) event_text = json.dumps(messages) if EXPECTED_TEXT not in event_text: raise AssertionError(f"direct runtime emitted no final response: {messages}") @@ -669,6 +639,16 @@ def smoke_direct(base_url: str, executable: Path) -> None: assert_session_log(sessions, root, EXPECTED_TEXT) +def is_idle_notification(message: dict[str, object]) -> bool: + """Return whether a JSON-RPC notification marks a session idle.""" + params = message.get("params") + return ( + message.get("method") == "session.status" + and isinstance(params, dict) + and params.get("status") == "idle" + ) + + class RuntimePeer: def __init__(self, argv: list[str], cwd: Path, environment: dict[str, str]) -> None: self.process = subprocess.Popen( @@ -776,7 +756,7 @@ def read_session_logs(sessions: Path) -> dict[str, list[dict[str, object]]]: return logs -def snapshot_child_ids(result: "TurnResult") -> list[str]: +def snapshot_child_ids(result: "RunResult") -> list[str]: """Return the two child session ids in their SDK notification order.""" child_ids: list[str] = [] for notification in result.notifications: @@ -794,7 +774,7 @@ def snapshot_child_ids(result: "TurnResult") -> list[str]: def build_snapshot_files( - result: "TurnResult", + result: "RunResult", logs: dict[str, list[dict[str, object]]], child_ids: list[str], cwd: Path, @@ -809,7 +789,6 @@ def build_snapshot_files( result_value = { "session_id": result.session_id, - "status": result.status, "final_response": result.final_response, "events": result.events, "notifications": [ @@ -834,7 +813,7 @@ def build_snapshot_files( return files -def snapshot_agent_id(result: "TurnResult", child_id: str) -> str: +def snapshot_agent_id(result: "RunResult", child_id: str) -> str: """Find the successful subagent id paired with one child session.""" for notification in result.notifications: if notification.method != "subagent.finished": diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 79daa0570c..dff04d578e 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -1,25 +1,62 @@ { "session_id": "{{parent}}", - "status": "ok", "final_response": "ADVANCED_EXECUTABLE_OK", "events": [ { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + }, + { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 + } + }, + { + "type": "agent/inbox/spliced", + "seq": 2, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] + } + }, + { + "type": "step/start", + "seq": 3, + "time": 0, + "data": { + "turn": 1, + "step": 1 } }, { "type": "user/message", - "seq": 1, + "seq": 4, "time": 0, "data": { "content": [ @@ -38,38 +75,34 @@ }, { "type": "session/title", - "seq": 2, + "seq": 5, "time": 0, "data": { "title": "Run the advanced packaged-runtime snapsh", "messageSeqs": [ - 1 + 4 ], "source": { "kind": "fallback" } } }, - { - "type": "step/start", - "seq": 3, - "time": 0, - "data": { - "turn": 1, - "step": 1 - } - }, { "type": "request/header", - "seq": 4, + "seq": 6, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -86,9 +119,19 @@ "reason": "initial" } }, + { + "type": "request/context", + "seq": 7, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + }, { "type": "assistant/chunk", - "seq": 5, + "seq": 8, "time": 0, "data": { "turn": 1, @@ -102,7 +145,7 @@ }, { "type": "assistant/chunk", - "seq": 6, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -118,7 +161,7 @@ }, { "type": "assistant/chunk", - "seq": 7, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -137,7 +180,7 @@ }, { "type": "assistant/chunk", - "seq": 8, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -153,7 +196,7 @@ }, { "type": "assistant/chunk", - "seq": 9, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -168,7 +211,7 @@ }, { "type": "assistant/message", - "seq": 10, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -185,7 +228,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -196,17 +239,17 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, 8, - 9 + 9, + 10, + 11, + 12 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 11, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -218,7 +261,7 @@ }, { "type": "tool/result", - "seq": 12, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -246,13 +289,13 @@ } }, "sourceEventSeqs": [ - 11 + 14 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 13, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -261,7 +304,7 @@ }, { "type": "step/start", - "seq": 14, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -270,15 +313,20 @@ }, { "type": "request/header", - "seq": 15, + "seq": 18, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -298,7 +346,7 @@ }, { "type": "assistant/chunk", - "seq": 16, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -312,7 +360,7 @@ }, { "type": "assistant/chunk", - "seq": 17, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -328,7 +376,7 @@ }, { "type": "assistant/chunk", - "seq": 18, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -347,7 +395,7 @@ }, { "type": "assistant/chunk", - "seq": 19, + "seq": 22, "time": 0, "data": { "turn": 1, @@ -363,7 +411,7 @@ }, { "type": "assistant/chunk", - "seq": 20, + "seq": 23, "time": 0, "data": { "turn": 1, @@ -378,7 +426,7 @@ }, { "type": "assistant/message", - "seq": 21, + "seq": 24, "time": 0, "data": { "turn": 1, @@ -395,7 +443,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -406,17 +454,17 @@ } }, "sourceEventSeqs": [ - 16, - 17, - 18, 19, - 20 + 20, + 21, + 22, + 23 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 22, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -428,9 +476,10 @@ }, { "type": "tool/code-dispatch-start", - "seq": 23, + "seq": 26, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -441,9 +490,10 @@ }, { "type": "tool/code-dispatch", - "seq": 24, + "seq": 27, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -461,7 +511,7 @@ }, { "type": "tool/result", - "seq": 25, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -489,13 +539,13 @@ } }, "sourceEventSeqs": [ - 22 + 25 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 26, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -504,7 +554,7 @@ }, { "type": "step/start", - "seq": 27, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -513,7 +563,7 @@ }, { "type": "assistant/chunk", - "seq": 28, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -527,7 +577,7 @@ }, { "type": "assistant/chunk", - "seq": 29, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -543,7 +593,7 @@ }, { "type": "assistant/chunk", - "seq": 30, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -562,7 +612,7 @@ }, { "type": "assistant/chunk", - "seq": 31, + "seq": 34, "time": 0, "data": { "turn": 1, @@ -578,7 +628,7 @@ }, { "type": "assistant/chunk", - "seq": 32, + "seq": 35, "time": 0, "data": { "turn": 1, @@ -593,7 +643,7 @@ }, { "type": "assistant/message", - "seq": 33, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -610,7 +660,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -621,17 +671,17 @@ } }, "sourceEventSeqs": [ - 28, - 29, - 30, 31, - 32 + 32, + 33, + 34, + 35 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 34, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -643,7 +693,7 @@ }, { "type": "tool/result", - "seq": 35, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -671,13 +721,13 @@ } }, "sourceEventSeqs": [ - 34 + 37 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 36, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -686,7 +736,7 @@ }, { "type": "step/start", - "seq": 37, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -695,7 +745,7 @@ }, { "type": "assistant/chunk", - "seq": 38, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -709,7 +759,7 @@ }, { "type": "assistant/chunk", - "seq": 39, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -725,7 +775,7 @@ }, { "type": "assistant/chunk", - "seq": 40, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -744,7 +794,7 @@ }, { "type": "assistant/chunk", - "seq": 41, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -760,7 +810,7 @@ }, { "type": "assistant/chunk", - "seq": 42, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -775,7 +825,7 @@ }, { "type": "assistant/message", - "seq": 43, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -792,7 +842,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -803,17 +853,17 @@ } }, "sourceEventSeqs": [ - 38, - 39, - 40, 41, - 42 + 42, + 43, + 44, + 45 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 44, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -825,7 +875,7 @@ }, { "type": "tool/result", - "seq": 45, + "seq": 48, "time": 0, "data": { "turn": 1, @@ -853,13 +903,13 @@ } }, "sourceEventSeqs": [ - 44 + 47 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 46, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -868,7 +918,7 @@ }, { "type": "step/start", - "seq": 47, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -877,7 +927,7 @@ }, { "type": "assistant/chunk", - "seq": 48, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -891,7 +941,7 @@ }, { "type": "assistant/chunk", - "seq": 49, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -907,7 +957,7 @@ }, { "type": "assistant/chunk", - "seq": 50, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -926,7 +976,7 @@ }, { "type": "assistant/chunk", - "seq": 51, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -942,7 +992,7 @@ }, { "type": "assistant/chunk", - "seq": 52, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -957,7 +1007,7 @@ }, { "type": "assistant/message", - "seq": 53, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -974,7 +1024,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -985,17 +1035,17 @@ } }, "sourceEventSeqs": [ - 48, - 49, - 50, 51, - 52 + 52, + 53, + 54, + 55 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 54, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -1007,7 +1057,7 @@ }, { "type": "tool/result", - "seq": 55, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -1035,13 +1085,13 @@ } }, "sourceEventSeqs": [ - 54 + 57 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 56, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -1050,7 +1100,7 @@ }, { "type": "step/start", - "seq": 57, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -1059,15 +1109,20 @@ }, { "type": "request/header", - "seq": 58, + "seq": 61, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -1086,7 +1141,7 @@ }, { "type": "assistant/chunk", - "seq": 59, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -1100,7 +1155,7 @@ }, { "type": "assistant/chunk", - "seq": 60, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -1114,7 +1169,7 @@ }, { "type": "assistant/chunk", - "seq": 61, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -1131,7 +1186,7 @@ }, { "type": "assistant/chunk", - "seq": 62, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -1147,7 +1202,7 @@ }, { "type": "assistant/chunk", - "seq": 63, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -1162,7 +1217,7 @@ }, { "type": "assistant/message", - "seq": 64, + "seq": 67, "time": 0, "data": { "turn": 1, @@ -1177,7 +1232,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -1188,17 +1243,17 @@ } }, "sourceEventSeqs": [ - 59, - 60, - 61, 62, - 63 + 63, + 64, + 65, + 66 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 65, + "seq": 68, "time": 0, "data": { "turn": 1, @@ -1207,7 +1262,7 @@ }, { "type": "turn/end", - "seq": 66, + "seq": 69, "time": 0, "data": { "turn": 1, @@ -1223,17 +1278,48 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + } + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{parent}}", + "status": "running" + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 } } } @@ -1243,42 +1329,14 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "user/message", - "seq": 1, - "time": 0, - "data": { - "content": [ - { - "type": "text", - "text": "Run the advanced packaged-runtime snapshot scenario." - } - ], - "source": { - "kind": "user" - }, - "role": "user", - "id": "{{messageId}}" - }, - "surfaceOp": "append" - } - } - }, - { - "method": "session.event", - "payload": { - "sessionId": "{{parent}}", - "event": { - "type": "session/title", + "type": "agent/inbox/spliced", "seq": 2, "time": 0, "data": { - "title": "Run the advanced packaged-runtime snapsh", - "messageSeqs": [ - 1 - ], - "source": { - "kind": "fallback" - } + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] } } } @@ -1303,16 +1361,66 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "request/header", + "type": "user/message", "seq": 4, "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" + }, + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "session/title", + "seq": 5, + "time": 0, + "data": { + "title": "Run the advanced packaged-runtime snapsh", + "messageSeqs": [ + 4 + ], + "source": { + "kind": "fallback" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/header", + "seq": 6, + "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -1331,13 +1439,29 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/context", + "seq": 7, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 5, + "seq": 8, "time": 0, "data": { "turn": 1, @@ -1357,7 +1481,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 6, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -1379,7 +1503,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 7, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -1404,7 +1528,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 8, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -1426,7 +1550,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -1447,7 +1571,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 10, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -1464,7 +1588,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -1475,11 +1599,11 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, 8, - 9 + 9, + 10, + 11, + 12 ], "surfaceOp": "append" } @@ -1491,7 +1615,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 11, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -1509,7 +1633,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 12, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -1537,7 +1661,7 @@ } }, "sourceEventSeqs": [ - 11 + 14 ], "surfaceOp": "append" } @@ -1549,7 +1673,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 13, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -1564,7 +1688,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 14, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -1579,15 +1703,20 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 15, + "seq": 18, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -1613,7 +1742,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 16, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -1633,7 +1762,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 17, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -1655,7 +1784,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 18, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -1680,7 +1809,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 19, + "seq": 22, "time": 0, "data": { "turn": 1, @@ -1702,7 +1831,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 20, + "seq": 23, "time": 0, "data": { "turn": 1, @@ -1723,7 +1852,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 21, + "seq": 24, "time": 0, "data": { "turn": 1, @@ -1740,7 +1869,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -1751,11 +1880,11 @@ } }, "sourceEventSeqs": [ - 16, - 17, - 18, 19, - 20 + 20, + 21, + 22, + 23 ], "surfaceOp": "append" } @@ -1767,7 +1896,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 22, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -1785,9 +1914,10 @@ "sessionId": "{{parent}}", "event": { "type": "tool/code-dispatch-start", - "seq": 23, + "seq": 26, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -1804,9 +1934,10 @@ "sessionId": "{{parent}}", "event": { "type": "tool/code-dispatch", - "seq": 24, + "seq": 27, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -1830,7 +1961,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 25, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -1858,7 +1989,7 @@ } }, "sourceEventSeqs": [ - 22 + 25 ], "surfaceOp": "append" } @@ -1870,7 +2001,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 26, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -1885,7 +2016,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 27, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -1900,7 +2031,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 28, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -1920,7 +2051,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 29, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -1942,7 +2073,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 30, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -1967,7 +2098,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 31, + "seq": 34, "time": 0, "data": { "turn": 1, @@ -1989,7 +2120,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 32, + "seq": 35, "time": 0, "data": { "turn": 1, @@ -2010,7 +2141,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 33, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -2027,7 +2158,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2038,11 +2169,11 @@ } }, "sourceEventSeqs": [ - 28, - 29, - 30, 31, - 32 + 32, + 33, + 34, + 35 ], "surfaceOp": "append" } @@ -2054,7 +2185,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 34, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -2078,17 +2209,97 @@ "payload": { "sessionId": "{{child-1}}", "event": { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Reply with exactly DIRECT_CHILD_OK and nothing else." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + } + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-1}}", + "status": "running" + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 2, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "subagent/descriptor", + "seq": 3, + "time": 0, + "data": { + "version": 2, + "mode": "one-shot", + "provider": "spawn", + "label": "Check direct child" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "step/start", + "seq": 4, + "time": 0, + "data": { + "turn": 1, + "step": 1 } } } @@ -2099,7 +2310,7 @@ "sessionId": "{{child-1}}", "event": { "type": "user/message", - "seq": 1, + "seq": 5, "time": 0, "data": { "content": [ @@ -2124,12 +2335,12 @@ "sessionId": "{{child-1}}", "event": { "type": "session/title", - "seq": 2, + "seq": 6, "time": 0, "data": { "title": "Reply with exactly DIRECT_CHILD_OK and", "messageSeqs": [ - 1 + 5 ], "source": { "kind": "fallback" @@ -2138,36 +2349,26 @@ } } }, - { - "method": "session.event", - "payload": { - "sessionId": "{{child-1}}", - "event": { - "type": "step/start", - "seq": 3, - "time": 0, - "data": { - "turn": 1, - "step": 1 - } - } - } - }, { "method": "session.event", "payload": { "sessionId": "{{child-1}}", "event": { "type": "request/header", - "seq": 4, + "seq": 7, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -2187,13 +2388,29 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "request/context", + "seq": 8, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 5, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -2213,7 +2430,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 6, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -2233,7 +2450,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 7, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -2256,7 +2473,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 8, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -2278,7 +2495,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -2299,7 +2516,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/message", - "seq": 10, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -2314,7 +2531,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2325,11 +2542,11 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, - 8, - 9 + 9, + 10, + 11, + 12, + 13 ], "surfaceOp": "append" } @@ -2341,7 +2558,7 @@ "sessionId": "{{child-1}}", "event": { "type": "step/end", - "seq": 11, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -2356,7 +2573,7 @@ "sessionId": "{{child-1}}", "event": { "type": "turn/end", - "seq": 12, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -2367,6 +2584,13 @@ } } }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-1}}", + "status": "idle" + } + }, { "method": "subagent.finished", "payload": { @@ -2390,7 +2614,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 35, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -2418,7 +2642,7 @@ } }, "sourceEventSeqs": [ - 34 + 37 ], "surfaceOp": "append" } @@ -2430,7 +2654,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 36, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -2445,7 +2669,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 37, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -2460,7 +2684,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 38, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -2480,7 +2704,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 39, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -2502,7 +2726,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 40, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -2527,7 +2751,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 41, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -2549,7 +2773,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 42, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -2570,7 +2794,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 43, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -2587,7 +2811,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2598,11 +2822,11 @@ } }, "sourceEventSeqs": [ - 38, - 39, - 40, 41, - 42 + 42, + 43, + 44, + 45 ], "surfaceOp": "append" } @@ -2614,7 +2838,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 44, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -2638,17 +2862,96 @@ "payload": { "sessionId": "{{child-2}}", "event": { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Reply with exactly WORKFLOW_CHILD_OK and nothing else." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + } + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-2}}", + "status": "running" + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 2, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "subagent/descriptor", + "seq": 3, + "time": 0, + "data": { + "version": 2, + "mode": "one-shot", + "provider": "spawn" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "step/start", + "seq": 4, + "time": 0, + "data": { + "turn": 1, + "step": 1 } } } @@ -2659,7 +2962,7 @@ "sessionId": "{{child-2}}", "event": { "type": "user/message", - "seq": 1, + "seq": 5, "time": 0, "data": { "content": [ @@ -2684,12 +2987,12 @@ "sessionId": "{{child-2}}", "event": { "type": "session/title", - "seq": 2, + "seq": 6, "time": 0, "data": { "title": "Reply with exactly WORKFLOW_CHILD_OK and", "messageSeqs": [ - 1 + 5 ], "source": { "kind": "fallback" @@ -2698,36 +3001,26 @@ } } }, - { - "method": "session.event", - "payload": { - "sessionId": "{{child-2}}", - "event": { - "type": "step/start", - "seq": 3, - "time": 0, - "data": { - "turn": 1, - "step": 1 - } - } - } - }, { "method": "session.event", "payload": { "sessionId": "{{child-2}}", "event": { "type": "request/header", - "seq": 4, + "seq": 7, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -2747,13 +3040,29 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "request/context", + "seq": 8, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 5, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -2773,7 +3082,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 6, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -2793,7 +3102,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 7, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -2816,7 +3125,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 8, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -2838,7 +3147,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -2859,7 +3168,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/message", - "seq": 10, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -2874,7 +3183,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2885,11 +3194,11 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, - 8, - 9 + 9, + 10, + 11, + 12, + 13 ], "surfaceOp": "append" } @@ -2901,7 +3210,7 @@ "sessionId": "{{child-2}}", "event": { "type": "step/end", - "seq": 11, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -2916,7 +3225,7 @@ "sessionId": "{{child-2}}", "event": { "type": "turn/end", - "seq": 12, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -2927,6 +3236,13 @@ } } }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-2}}", + "status": "idle" + } + }, { "method": "subagent.finished", "payload": { @@ -2950,7 +3266,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 45, + "seq": 48, "time": 0, "data": { "turn": 1, @@ -2978,7 +3294,7 @@ } }, "sourceEventSeqs": [ - 44 + 47 ], "surfaceOp": "append" } @@ -2990,7 +3306,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 46, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -3005,7 +3321,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 47, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -3020,7 +3336,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 48, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -3040,7 +3356,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 49, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -3062,7 +3378,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 50, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -3087,7 +3403,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 51, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -3109,7 +3425,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 52, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -3130,7 +3446,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 53, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -3147,7 +3463,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -3158,11 +3474,11 @@ } }, "sourceEventSeqs": [ - 48, - 49, - 50, 51, - 52 + 52, + 53, + 54, + 55 ], "surfaceOp": "append" } @@ -3174,7 +3490,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 54, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -3192,7 +3508,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 55, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -3220,7 +3536,7 @@ } }, "sourceEventSeqs": [ - 54 + 57 ], "surfaceOp": "append" } @@ -3232,7 +3548,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 56, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -3247,7 +3563,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 57, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -3262,15 +3578,20 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 58, + "seq": 61, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -3295,7 +3616,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 59, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -3315,7 +3636,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 60, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -3335,7 +3656,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 61, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -3358,7 +3679,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 62, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -3380,7 +3701,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 63, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -3401,7 +3722,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 64, + "seq": 67, "time": 0, "data": { "turn": 1, @@ -3416,7 +3737,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -3427,11 +3748,11 @@ } }, "sourceEventSeqs": [ - 59, - 60, - 61, 62, - 63 + 63, + 64, + 65, + 66 ], "surfaceOp": "append" } @@ -3443,7 +3764,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 65, + "seq": 68, "time": 0, "data": { "turn": 1, @@ -3458,7 +3779,7 @@ "sessionId": "{{parent}}", "event": { "type": "turn/end", - "seq": 66, + "seq": 69, "time": 0, "data": { "turn": 1, @@ -3470,13 +3791,10 @@ } }, { - "method": "session.finished", + "method": "session.status", "payload": { "sessionId": "{{parent}}", - "status": "ok", - "reason": { - "kind": "completed" - } + "status": "idle" } } ], diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index 2929f8664c..3cfcda4d28 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -1,14 +1,18 @@ -{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1} +{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index a5da33d006..926acbcecc 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -1,14 +1,18 @@ -{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1} +{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl index 1f2f890b3c..65f31b21b6 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -1,68 +1,71 @@ {"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"<anonymous>\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} -{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} -{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} -{"type":"tool/code-dispatch-start","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} -{"type":"tool/code-dispatch","seq":24,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} -{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} -{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} -{"type":"tool/call","seq":34,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} -{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} -{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} -{"type":"tool/call","seq":44,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} -{"type":"tool/result","seq":45,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"} -{"type":"step/end","seq":46,"time":0,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":47,"time":0,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} -{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} -{"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} -{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"} -{"type":"step/end","seq":56,"time":0,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":57,"time":0,"data":{"turn":1,"step":6}} -{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} -{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} -{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} -{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} -{"type":"step/end","seq":65,"time":0,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":66,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}} +{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"<anonymous>\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}} +{"type":"request/header","seq":18,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} +{"type":"tool/code-dispatch-start","seq":26,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} +{"type":"tool/code-dispatch","seq":27,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} +{"type":"tool/result","seq":28,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":30,"time":0,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":36,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[31,32,33,34,35],"surfaceOp":"append"} +{"type":"tool/call","seq":37,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":38,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[37],"surfaceOp":"append"} +{"type":"step/end","seq":39,"time":0,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":40,"time":0,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} +{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} +{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":46,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"} +{"type":"tool/call","seq":47,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} +{"type":"tool/result","seq":48,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[47],"surfaceOp":"append"} +{"type":"step/end","seq":49,"time":0,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":50,"time":0,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":56,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[51,52,53,54,55],"surfaceOp":"append"} +{"type":"tool/call","seq":57,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} +{"type":"tool/result","seq":58,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[57],"surfaceOp":"append"} +{"type":"step/end","seq":59,"time":0,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":60,"time":0,"data":{"turn":1,"step":6}} +{"type":"request/header","seq":61,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} +{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} +{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} +{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":67,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[62,63,64,65,66],"surfaceOp":"append"} +{"type":"step/end","seq":68,"time":0,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":69,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} From b90cb1b0b0b5dd7c01c6af0eafd73fae78d6f4ca Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 20:55:32 +0800 Subject: [PATCH 123/229] test(web): re-record the subagent goldens against a rebuilt client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The child session now shows the preset badge its parent shows, which is the visible consequence of recording the composition it runs. The first recording of these two goldens was taken against a dist built before the master merge, so it captured the fallback Chinese label instead of the English one `newEnglishPage` pins — the web lane replays the BUILT client, and a stale build reads as a product difference. Re-recorded after `pnpm run build`. --- apps/web/tests/snapshots/subagent-conversation/ui.expected.md | 4 ++-- .../snapshots/subagent-interrupt/offline-composer.expected.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 4b71dfdc9c..cf22f5b566 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -3,11 +3,11 @@ - button "Ask a research subagent to" - text: / - button "event-sourcing researcher" [disabled] + - img + - text: Standard mode - button "1 subagent": - text: 1 subagent - img - - img - - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md index fbec36baea..7fe41a532d 100644 --- a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md +++ b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md @@ -4,7 +4,7 @@ - text: / - button "event-sourcing researcher" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" From 854f6623bb0334c6175df179d71d19fe345924f6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:38:57 +0800 Subject: [PATCH 124/229] docs: rename client manifest field references --- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 4 ++-- ...026-07-19-gui-layering-and-rpc-protocol.md | 8 ++++---- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 8 ++++---- ...7-19-gui-web-client-architecture.i18n.yaml | 4 ++-- .../2026-07-19-gui-web-client-architecture.md | 4 ++-- ...26-07-19-gui-web-client-architecture.zh.md | 4 ++-- ...7-23-client-plugin-loading-model.i18n.yaml | 4 ++-- .../2026-07-23-client-plugin-loading-model.md | 20 +++++++++---------- ...26-07-23-client-plugin-loading-model.zh.md | 20 +++++++++---------- ...tree-boot-and-transport-layering.i18n.yaml | 4 ++-- ...config-tree-boot-and-transport-layering.md | 2 +- ...fig-tree-boot-and-transport-layering.zh.md | 2 +- ...07-24-web-session-model-selector.i18n.yaml | 4 ++-- .../2026-07-24-web-session-model-selector.md | 2 +- ...026-07-24-web-session-model-selector.zh.md | 2 +- docs/api-gateway.i18n.yaml | 4 ++-- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- docs/capability-seams.md | 2 +- docs/cookbook/adding-a-package.i18n.yaml | 4 ++-- docs/cookbook/adding-a-package.md | 2 +- docs/cookbook/adding-a-package.zh.md | 2 +- packages/boot/app-boot/src/profile.ts | 9 ++++----- packages/bundle/web-app/cordis.patch.yml | 6 +++--- packages/client/AGENTS.md | 6 +++--- packages/client/modules/README.i18n.yaml | 4 ++-- packages/client/modules/README.md | 2 +- packages/client/modules/README.zh.md | 2 +- .../client/modules/src/client/manifest.ts | 2 +- .../client/runtime/tests/node-half.spec.ts | 2 +- packages/client/test-runtime/README.i18n.yaml | 4 ++-- packages/client/test-runtime/README.md | 2 +- packages/client/test-runtime/README.zh.md | 2 +- packages/client/test-runtime/src/index.ts | 2 +- packages/client/ui-command/src/index.ts | 2 +- packages/client/ui-deliverables/src/index.ts | 2 +- packages/client/ui-goal/src/index.ts | 2 +- packages/client/ui-model/src/index.ts | 2 +- packages/client/ui-permission/src/index.ts | 2 +- packages/client/ui-plan/src/index.ts | 2 +- .../client/ui-settings/src/client/index.ts | 2 +- packages/client/ui-skill/src/index.ts | 2 +- packages/client/ui-slash/src/index.ts | 2 +- packages/client/ui-subagent/src/index.ts | 2 +- .../client/ui-workspace/src/client/index.ts | 2 +- packages/client/ui-workspace/src/index.ts | 2 +- scripts/gen-doc-graphs.ts | 2 +- 47 files changed, 89 insertions(+), 90 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index c7be4bbdf1..bc2d26325d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: f9c95176321496e965a95b6358d6feaa8466fe89 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 7d20c5a2662c9036382b30a96bc9973c8f0349bd +2026-07-19-gui-layering-and-rpc-protocol.md: 514deb890d4e08d465db869669078473d32fb215 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: f6fa71e3dac25f48b2ad4744a0cc695417528b34 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index f9c9517632..514deb890d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -27,8 +27,8 @@ Directories layer as follows: - the unified backend protocol (fetch, HTTP, streaming interfaces…) — definitions and support, see the "Message protocol" sections below - `packages/client/*`: packages provide client-side capability only; every package stays single-sided. Three kinds live here (the axes are owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md)): - **Pure libraries** (`ui-slots`, `web-react`, `ui-primitives`, plus the `loader` kernel package): ordinary root-index packages, statically bundled into the shell; the first three are seeded into the module table. - - **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dshClient` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else. - - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. + - **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dsh.client` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else. + - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dsh.client` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported applications, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. @@ -40,7 +40,7 @@ apps/* (applications: apps/web = vite app, apps/cli = bin dispatch) ▼ packages/host/* packages/client/* apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives - runtime assembly / host entity dshClient plugins ×8 (node half = empty apply, + runtime assembly / host entity dsh.client plugins ×8 (node half = empty apply, webserver Web HTTP carriage client half = src/client/) │ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths ▼ │ (type-only + the client base class) @@ -63,7 +63,7 @@ On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Nod | Layer | Package | Responsibility | Key discipline | |---|---|---|---| | Front layer | `dsh-host-apiproxy` | TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx | -| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly | +| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dsh.client packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly | | Carrier layer | `dsh-host-webserver` | Web HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | | Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell | | Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture note | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index 7d20c5a266..f6fa71e3da 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -25,8 +25,8 @@ Status: implemented - 统一后端协议(fetch、HTTP、流式接口等)定义和支持,见本篇「消息协议」起各节 - `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有): - **纯库**(`ui-slots`、`web-react`、`ui-primitives`,外加内核包 `loader`):普通根入口包,静态打包进壳;前三者播种进模块表。 - - **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dshClient` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。 - - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 + - **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dsh.client` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。 + - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 @@ -38,7 +38,7 @@ apps/* (applications: apps/web = vite app, apps/cli = bin dispatch) ▼ packages/host/* packages/client/* apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives - runtime assembly / host entity dshClient plugins ×8 (node half = empty apply, + runtime assembly / host entity dsh.client plugins ×8 (node half = empty apply, webserver Web HTTP carriage client half = src/client/) │ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths ▼ │ (type-only + the client base class) @@ -61,7 +61,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. | 层 | 包 | 职责 | 关键纪律 | |---|---|---|---| | 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/:handler + 客户端基类) | 做简单、所有接入方都要;Node/浏览器皆可 import;协议内容见下文「消息协议」起各节;client 不得经 ctx 绕开 api | -| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 | +| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dsh.client 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 | | 承载层 | `dsh-host-webserver` | Web HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | | client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 | | client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构笔记 | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 269933c1a7..1712fa8304 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 82b2f85708c423748954644d4991e2d54d42874a -2026-07-19-gui-web-client-architecture.zh.md: c37252d1db291cae11db2a615c9e4005ece717da +2026-07-19-gui-web-client-architecture.md: bc61aab894d587820ef4cb568b6439993a27d30d +2026-07-19-gui-web-client-architecture.zh.md: 1f5bafe1dff878b5ca5ffcbdb9ed8ca38a863c9f diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 82b2f85708..bc61aab894 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -30,7 +30,7 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon ## The client cordis tree and the loading chain -The loading chain — the two package kinds (plain vs dshClient plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` contract; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — every production plugin package (infrastructure included) carries the `dshClient` declaration and arrives as a fetched `./client` tsdown closure bundle, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `<style data-plugin="<id>">` at materialization (CSS Modules hashing + ownership tag = isolation, removal on reload); hot reload is live in dev graphs — the webserver stat-polls the bundles it serves and broadcasts `rebuilt` SSE frames, and the `client-hmr` plugin swaps one fiber per frame. The settled flip (`loader.await()` + an all-ACTIVE sweep) still switches the shell from the loading page to the real UI in one pass — settled means every entry is created and every fiber reached ACTIVE, with FAILED/PENDING fibers listed loud; there is no partial-availability mode (progressive rendering is deferred work). +The loading chain — the two package kinds (plain vs dsh.client plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` contract; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — every production plugin package (infrastructure included) carries the `dsh.client` declaration and arrives as a fetched `./client` tsdown closure bundle, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `<style data-plugin="<id>">` at materialization (CSS Modules hashing + ownership tag = isolation, removal on reload); hot reload is live in dev graphs — the webserver stat-polls the bundles it serves and broadcasts `rebuilt` SSE frames, and the `client-hmr` plugin swaps one fiber per frame. The settled flip (`loader.await()` + an all-ACTIVE sweep) still switches the shell from the loading page to the real UI in one pass — settled means every entry is created and every fiber reached ACTIVE, with FAILED/PENDING fibers listed loud; there is no partial-availability mode (progressive rendering is deferred work). Type universes stay split at the aggregate level — `tsconfig.host.json` is the host program and `tsconfig.client.json` the client program, both referenced by the solution root `tsconfig.json` — because both sides merge cordis `Context` under the same keys (`sessions`, `loader`) with different services; client packages consume the wire vocabulary through pure type subpaths (`@deepseek-ai/dsh-session/types` and kin) so no host augmentation rides into the client program. @@ -108,7 +108,7 @@ Domain implementation files never import a sibling domain; shared surfaces route ## How to develop -- **A new UI feature** = a new plugin package: declare `dshClient` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores and registers slots), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically. +- **A new UI feature** = a new plugin package: declare `dsh.client` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores and registers slots), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically. - **A new slot**: see the [slot system standard note](2026-07-22-slot-type-chain-implementation.md) — merge the contract into `SlotMap`, declare it in the parent entry's `children`, render through the auto-injected `renderSlot` prop. Never export components globally. - **Consuming a new frame type**: transport-only session frames → Session's dispatch switch; host-level frames → the Manager routing table; logged conversation business events → a Definition plus a keyed view renderer, without a Session business branch. - **Where does this state live**: business data (events, streaming, pending) → always the object layer; what the parent knows → owner props at the renderSlot site; private to one component (scroll, search text, expansion) → component state; shared across entries or surviving remounts (selection, drafts, panel widths) → an entry-declared store ([slot system standard](2026-07-22-slot-type-chain-implementation.md)). diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index c37252d1db..1f5bafe1df 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -30,7 +30,7 @@ Status: implemented ## client cordis 树与装载链 -装载链——两类包(普通包 vs dshClient 插件)、模块系统/插件治理器之分、host 独家撰写的带修订号 entry 图之上的双层 boot、热重载——归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有。本篇赖以立足的事实:浏览器启动与 host 相同的 vendored `@cordisjs/plugin-loader`,由 client 模块系统(`ctx.modules`,`packages/client/modules`)填上其 `internal` 约定;凡带产品行为的单元都是 host 独家撰写的 `__DSH_BOOT__` 图里的 entry——每个生产插件包(含基础设施)都携带 `dshClient` 声明、以 fetch 到达的 `./client` tsdown 闭包 bundle 供给,`immediately` 行的差别仅在 boot 第一层预取,而普通包(react 家族、cordis、尚未升格的库)保持打进壳、已播种、对图不可见;bundle 执行 `window.__ModuleLoader__.load({ id, factory })`,其 `require` 由 lazy CJS 模块表应答(种子词条 + 已登记工厂,首次 require 时物化并记忆化——跨插件值 import 是构建错误,协作走 cordis 服务);插件 CSS 内联在 bundle 里、物化时注入为 `<style data-plugin="<id>">`(CSS Modules 哈希 + 归属标记 = 隔离,重载时移除);热重载已在 dev 图落地——webserver 对自己供给的 bundle 做 stat 轮询并广播 `rebuilt` SSE 帧,`client-hmr` 插件每帧换掉一个 fiber。settled 翻转(`loader.await()` + 一次全 ACTIVE 扫描)依旧让壳从 loading 页一次切换到真 UI——settled 意味着每个 entry 已创建、每个 fiber 都到达 ACTIVE,FAILED/PENDING 的 fiber 被大声列出;不存在部分可用模式(渐进渲染为后置工作)。 +装载链——两类包(普通包 vs dsh.client 插件)、模块系统/插件治理器之分、host 独家撰写的带修订号 entry 图之上的双层 boot、热重载——归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有。本篇赖以立足的事实:浏览器启动与 host 相同的 vendored `@cordisjs/plugin-loader`,由 client 模块系统(`ctx.modules`,`packages/client/modules`)填上其 `internal` 约定;凡带产品行为的单元都是 host 独家撰写的 `__DSH_BOOT__` 图里的 entry——每个生产插件包(含基础设施)都携带 `dsh.client` 声明、以 fetch 到达的 `./client` tsdown 闭包 bundle 供给,`immediately` 行的差别仅在 boot 第一层预取,而普通包(react 家族、cordis、尚未升格的库)保持打进壳、已播种、对图不可见;bundle 执行 `window.__ModuleLoader__.load({ id, factory })`,其 `require` 由 lazy CJS 模块表应答(种子词条 + 已登记工厂,首次 require 时物化并记忆化——跨插件值 import 是构建错误,协作走 cordis 服务);插件 CSS 内联在 bundle 里、物化时注入为 `<style data-plugin="<id>">`(CSS Modules 哈希 + 归属标记 = 隔离,重载时移除);热重载已在 dev 图落地——webserver 对自己供给的 bundle 做 stat 轮询并广播 `rebuilt` SSE 帧,`client-hmr` 插件每帧换掉一个 fiber。settled 翻转(`loader.await()` + 一次全 ACTIVE 扫描)依旧让壳从 loading 页一次切换到真 UI——settled 意味着每个 entry 已创建、每个 fiber 都到达 ACTIVE,FAILED/PENDING 的 fiber 被大声列出;不存在部分可用模式(渐进渲染为后置工作)。 类型宇宙在聚合层拆分——`tsconfig.host.json` 是 host program、`tsconfig.client.json` 是 client program,二者由 solution 根 `tsconfig.json` 引用,因为两侧都在相同键(`sessions`、`loader`)上对 cordis `Context` 做声明合并且服务不同;client 包经纯类型子路径(`@deepseek-ai/dsh-session/types` 等)消费协议词汇,host 侧的声明合并不会搭车进入 client program。 @@ -108,7 +108,7 @@ src/client/ ## 怎么开发 -- **新 UI 功能** = 新插件包:package.json 声明 `dshClient`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;manifest 与装载随之自动跟上。 +- **新 UI 功能** = 新插件包:package.json 声明 `dsh.client`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;manifest 与装载随之自动跟上。 - **新 slot**:见 [slot 体系标准笔记](2026-07-22-slot-type-chain-implementation.md)——约定合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。 - **消费新帧类型**:纯传输 session frame → Session 分发 switch;host 级 frame → Manager 路由表;已记录的 conversation 业务事件 → Definition 加 keyed view renderer,不增加 Session 业务分支。 - **状态住哪**:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store([slot 体系标准](2026-07-22-slot-type-chain-implementation.md))。 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index bdeff6702d..85012597e6 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: fff96f65a21d9527c8fa49589b178c490bacdd5a -2026-07-23-client-plugin-loading-model.zh.md: 0c0c95ba7ebffca33c2c2d1dec13f745c4316f43 +2026-07-23-client-plugin-loading-model.md: 21289c5dcebc7244e98c602e9f10bac7eb365bc3 +2026-07-23-client-plugin-loading-model.zh.md: c3c4ef598c1d92d4ebec7b9691d31cf33c7c62a2 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index fff96f65a2..21289c5dce 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -1,4 +1,4 @@ -# Agent Note: Client plugin loading — plain packages, dshClient plugins, and the two-phase boot +# Agent Note: Client plugin loading — plain packages, dsh.client plugins, and the two-phase boot Status: implemented @@ -18,22 +18,22 @@ The lower layer supplies four capabilities: externals (the platform list), remot Plugin bundles are built independently outside Vite's module graph. Feeding response text into an inline script leaves the browser with a dynamic source execution: no standard source-map chain connects the network resource, generated bundle, and TypeScript/TSX source, so performance profiles and stacks stop at generated `client.js`; the module system must also buffer the complete source and split one arrival responsibility across fetch and execute transport boundaries. -On top of that, client and host plugins register and load consistently: a package declares `dshClient` once, the host scans the declaration into the boot graph, and the same Loader semantics govern entries on both sides. +On top of that, client and host plugins register and load consistently: a package declares `dsh.client` once, the host scans the declaration into the boot graph, and the same Loader semantics govern entries on both sides. The first-generation client loader (`createClientLoader`) hand-wrote both layers in one function. The fusion left no unload/reload path (loads were one-shot, style tags never removed), hand-copied dependency lists that had already drifted across three files, and a module-table backdoor for cross-plugin imports that duplicated cordis's service mechanism while making load order a correctness constraint. The structure below replaced it. ## Decision -### Two package kinds; `dshClient` means plugin, period +### Two package kinds; `dsh.client` means plugin, period What makes a package a plugin? One rule: **a package is a plugin package once its consumption is cordis dependency injection; until then it is a plain package.** How code reaches the page is not part of the taxonomy — arrival follows from the kind instead of defining it. - **Plain packages** are the absolute base the module system itself needs, plus libraries not yet converted to DI: the react family, cordis, `@deepseek-ai/dsh-client-modules` (the module system itself — it can never be a plugin, because modules precede all modules), the web shell kernel, and — for now — ui-slots, web-react, ui-primitives. Plain packages are shell-bundled, seeded into the module table, and invisible to the host graph. -- **Plugin packages** are everything else. Each one carries a `dshClient` manifest declaration (`{ platform, inject, immediately? }`) and one uniform shape: the shared tsdown preset emits `lib/client.js`, and `exports["./client"]` points at that bundle. Each is a governed entry of the host-authored graph. The current set is connection, runtime, ui-theme, i18n, hmr (dev graphs only), ui-layout, ui-sidebar, ui-conversation, ui-model-selector, ui-question, and ui-trajectory. +- **Plugin packages** are everything else. Each one carries a `dsh.client` manifest declaration (`{ platform, inject, immediately? }`) and one uniform shape: the shared tsdown preset emits `lib/client.js`, and `exports["./client"]` points at that bundle. Each is a governed entry of the host-authored graph. The current set is connection, runtime, ui-theme, i18n, hmr (dev graphs only), ui-layout, ui-sidebar, ui-conversation, ui-model-selector, ui-question, and ui-trajectory. The manifest owns the package's loading contract: its `inject` dependency edges, plus the optional `immediately` prefetch mark (absent means lazy). The composing app owns only the roster and the `--dev` switch. -To add a plugin package: declare `dshClient`, emit the `./client` bundle through the shared preset, add the name to the composing app's roster. Nothing else changes hands. +To add a plugin package: declare `dsh.client`, emit the `./client` bundle through the shared preset, add the name to the composing app's roster. Nothing else changes hands. When does a plain package become a plugin? The upgrade law, recorded so the migration path stays honest: **a plain package becomes a plugin package when its consumers switch to cordis DI, not before.** Three promotions are queued: ui-slots (the slots machinery now living in runtime — SlotsService, the renderer contract, the root slot), web-react (the renderer install moving into its own `apply`), and ui-primitives (once components are served through slots/services). Until then they stay plain, and their symbol exports stay ordinary static imports. @@ -67,10 +67,10 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the **Host side — compose the graph.** 1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the host activation audit so the same check covers it. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)). -2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber. +2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber. 3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is served as a script resource at `/plugins/<id>/client.js?rev=…`, with its source map at the same path plus `.map`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself). -Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted. +Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted. **Phase one — the module face.** The shell builds the module system over the graph, then prefetches every `immediately` row in parallel. Prefetch loads the external script and registers its factory only. A single row's prefetch failure is swallowed here: phase two's import retries the load and owns the loud failure, so one bad row cannot mask the others. `immediately` is a prefetch mark — not a barrier, not an identity. The package declares it, the registry carries it into the row. The infrastructure plugins (connection, runtime, ui-theme, i18n, plus hmr) declare it; UI plugins simply arrive on demand. @@ -86,7 +86,7 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a Whether hot reload is active is a composition decision: dev compositions mount the `client-hmr` row (a normal plugin package, appended by `--dev`) whose node half brings the bundle watch and the SSE channel; prod compositions mount nothing and have neither. -How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads bundle paths from `ctx.clientModuleHost.clientPath(id)`, and one HMR-owned interval stat-polls every current graph row. Adding a row is ordered as synchronous stat baseline, then immediate `clientModuleHost.rebuilt(id)`: a write after the module host's graph hash but before that baseline is caught by the immediate re-hash, while a write after the baseline leaves a stat delta for the next poll. This avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a mtime/size delta or dirty row, `clientModuleHost.rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, its package list dshClient-discovered by scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev. +How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads bundle paths from `ctx.clientModuleHost.clientPath(id)`, and one HMR-owned interval stat-polls every current graph row. Adding a row is ordered as synchronous stat baseline, then immediate `clientModuleHost.rebuilt(id)`: a write after the module host's graph hash but before that baseline is caught by the immediate re-hash, while a write after the baseline leaves a stat delta for the next poll. This avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a mtime/size delta or dirty row, `clientModuleHost.rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev. On the browser side, the driver reloads one plugin per frame, serialized: @@ -113,7 +113,7 @@ The support boundary, stated honestly. Reload is coarse by design: fresh fiber, | `dsh-client-ui-slots` | slot registry core | plain, seeded | promote to plugin; receive runtime's slots machinery | | `dsh-client-web-react` | ctx↔React glue | plain, seeded | promote to plugin; renderer install moves into its apply | | `dsh-client-ui-primitives` | base components | plain, seeded | promote to plugin (components via slots/services) | -| `dsh-client-connection` | wire layer | plugin (dshClient + bundle), declares `immediately` | transport swap (Electron IPC carrier) | +| `dsh-client-connection` | wire layer | plugin (`dsh.client` + bundle), declares `immediately` | transport swap (Electron IPC carrier) | | `dsh-client-runtime` | session object layer + slots service + store engine | plugin, declares `immediately` | keeps shrinking toward a pure session object layer | | `dsh-client-ui-theme` | theme tokens/service | plugin, declares `immediately`, plus the `./styles/*` source channel | Theme Registry (separate ruling) | | `dsh-client-i18n` | I18nService | plugin, declares `immediately` | per-deployment locale composition | @@ -132,7 +132,7 @@ Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordi | Rejected | One-line reason | |---|---| -| Two-axis taxonomy (entry × arrival) with non-dshClient infrastructure packages | Erased manifest dependency edges (inject leaked to the composer), split the plugin shape in two, blinded the purity gate to half the plugins | +| Two-axis taxonomy (entry × arrival) with infrastructure packages lacking `dsh.client` | Erased manifest dependency edges (inject leaked to the composer), split the plugin shape in two, blinded the purity gate to half the plugins | | Keep evolving the hand-written loader into a governor | Re-implements entry/fiber lifecycle the vendored Loader owns; HMR would have no shared skeleton with the host side | | Reuse `@cordisjs/plugin-hmr` in the browser | ~80% solves problems the browser doesn't have (fs watching, deep graph coloring, Node's dual caches); the reload skeleton is copied as a shape | | Module federation | Independently built remote bundles are exactly the form vite federation does not support | diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 0c0c95ba7e..c3c4ef598c 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -1,4 +1,4 @@ -# Agent Note: client 插件装载——普通包、dshClient 插件与双阶段 boot +# Agent Note: client 插件装载——普通包、dsh.client 插件与双阶段 boot Status: implemented @@ -18,22 +18,22 @@ host 侧,cordis 插件装载站在 Node 的模块机制之上——require cac 插件 bundle 独立构建在 Vite 模块图之外。若把响应文本塞进内联 script,浏览器只能看到一次动态源码执行:网络资源、生成 bundle、TypeScript/TSX 源码之间没有标准 sourcemap 链,性能 profile 与 stack 只能落到生成后的 `client.js`;模块系统还要持有整份源码文本,并把同一项到达职责拆成 fetch 与 execute 两道传输边界。 -在此之上,client 与 host 插件以一致的方式注册与装载:包声明一次 `dshClient`,host 把声明扫描进 boot 图,同一套 Loader 语义在两侧治理 entry。 +在此之上,client 与 host 插件以一致的方式注册与装载:包声明一次 `dsh.client`,host 把声明扫描进 boot 图,同一套 Loader 语义在两侧治理 entry。 第一代 client loader(`createClientLoader`)把这两层手写进了同一个函数。这一融合留下的是:没有卸载/重载路径(装载一次性,style 标签从不移除)、在三个文件间人肉抄写且早已漂移的依赖清单、一条供跨插件 import 走的模块表后门——既复制了 cordis 的服务机制,又把装载顺序变成正确性约束。下文的结构取代了它。 ## Decision -### 两类包;`dshClient` 即插件,别无他义 +### 两类包;`dsh.client` 即插件,别无他义 什么让一个包成为插件?只有一条规则:**一个包的消费方式一旦是 cordis 依赖注入,它就是插件包;在此之前它是普通包。**代码怎么到达页面不属于分类体系——到达方式由包的类别推得,而不是反过来定义类别。 - **普通包**是模块系统自身所需的绝对基座,加上尚未转成 DI 的库:react 家族、cordis、`@deepseek-ai/dsh-client-modules`(模块系统本身——它永远不可能是插件,因为模块先于一切模块)、web 壳内核,以及——暂时——ui-slots、web-react、ui-primitives。普通包打进壳 bundle、播种进模块表、对 host 图不可见。 -- **插件包**是其余一切。每个都携带 `dshClient` manifest(元数据清单)声明(`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js`,`exports["./client"]` 指向该 bundle。每个都是 host 独家撰写的图里受治理的 entry。当前包括:connection、runtime、ui-theme、i18n、hmr(仅进 dev 图)、ui-layout、ui-sidebar、ui-conversation、ui-model-selector、ui-question、ui-trajectory。 +- **插件包**是其余一切。每个都携带 `dsh.client` manifest(元数据清单)声明(`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js`,`exports["./client"]` 指向该 bundle。每个都是 host 独家撰写的图里受治理的 entry。当前包括:connection、runtime、ui-theme、i18n、hmr(仅进 dev 图)、ui-layout、ui-sidebar、ui-conversation、ui-model-selector、ui-question、ui-trajectory。 manifest 拥有包的装载约定:它的 `inject` 依赖边,加可选的 `immediately` 预取标记(缺省即 lazy)。负责组合的 app 只拥有名册与 `--dev` 开关。 -新增一个插件包:声明 `dshClient`,经共享预设产出 `./client` bundle,把包名加进负责组合的 app 的名册。除此之外无需任何交接。 +新增一个插件包:声明 `dsh.client`,经共享预设产出 `./client` bundle,把包名加进负责组合的 app 的名册。除此之外无需任何交接。 普通包何时升格为插件?升级法则,记录在案让迁移路径保持诚实:**普通包在其消费方改用 cordis DI 之时升格为插件包,绝不提前。**三项升格在排队:ui-slots(现居 runtime 的 slots 机件——SlotsService、渲染器约定、root slot)、web-react(渲染器安装移入自己的 `apply`)、ui-primitives(组件经 slot/服务供给之时)。在那之前它们保持普通包身份,符号导出保持普通的静态 import。 @@ -67,10 +67,10 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点 **host 侧——组合这张图。** 1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 host 激活检查之前追加 `client-hmr` 行,使同一项检查覆盖它。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。 -2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误。 +2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dsh.client` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误。 3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都作为脚本资源供给:`/plugins/<id>/client.js?rev=…`,对应 sourcemap 位于同一路径加 `.map`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)。 -为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。 +为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dsh.client 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。 **第一阶段——模块面。**壳在图之上建起模块系统,然后并行预取每个 `immediately` 行。预取即加载外部脚本,只登记工厂。单行预取失败在这里被吞下:第二阶段 import 时会重试加载并拥有那次大声失败,因此一个坏行藏不住其他行。`immediately` 是预取标记——不是屏障,不是身份。包声明它,注册表把它带进图行。基础设施插件(connection、runtime、ui-theme、i18n,外加 hmr)声明它;UI 插件则径直按需到达。 @@ -86,7 +86,7 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点 热重载是否启用是一项组合决策:dev 组合挂载 `client-hmr` 行(一个常规的插件包,由 `--dev` 追加),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;prod 组合不挂载,两者皆无。 -重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModuleHost.clientPath(id)` 读取图上各行的 bundle 路径,由 HMR 自持的单个定时器对当前图上的每一行做 stat 轮询。新增图行时,顺序固定为先同步取得 stat 基线,再立即调用 `clientModuleHost.rebuilt(id)`:在模块 host 算出图哈希之后、取得基线之前发生的写入会被这次立即重哈希捕获;取得基线之后发生的写入则会留下 stat 差异,供下一次轮询捕获。这避开了 `fs.watchFile`:它以异步首次 stat 建立基线,可能把构造期间的重建静默吸收进基线。监视集合的成员随 `onGraphChanged` 更新;消失的行撤下监视,轮询时缺失的 bundle 则让对应行保持标脏状态,文件重现时即使元数据相同也强制重哈希。mtime/size 变化或行处于标脏状态时,`clientModuleHost.rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dshClient 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。 +重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModuleHost.clientPath(id)` 读取图上各行的 bundle 路径,由 HMR 自持的单个定时器对当前图上的每一行做 stat 轮询。新增图行时,顺序固定为先同步取得 stat 基线,再立即调用 `clientModuleHost.rebuilt(id)`:在模块 host 算出图哈希之后、取得基线之前发生的写入会被这次立即重哈希捕获;取得基线之后发生的写入则会留下 stat 差异,供下一次轮询捕获。这避开了 `fs.watchFile`:它以异步首次 stat 建立基线,可能把构造期间的重建静默吸收进基线。监视集合的成员随 `onGraphChanged` 更新;消失的行撤下监视,轮询时缺失的 bundle 则让对应行保持标脏状态,文件重现时即使元数据相同也强制重哈希。mtime/size 变化或行处于标脏状态时,`clientModuleHost.rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。 浏览器侧,驱动插件每帧重载一个插件,串行执行: @@ -113,7 +113,7 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点 | `dsh-client-ui-slots` | slot 注册表核心 | 普通包,已播种 | 升格为插件;接收 runtime 的 slots 机件 | | `dsh-client-web-react` | ctx↔React 胶水 | 普通包,已播种 | 升格为插件;渲染器安装移入其 apply | | `dsh-client-ui-primitives` | 基础组件 | 普通包,已播种 | 升格为插件(组件经 slot/服务供给) | -| `dsh-client-connection` | wire 层 | 插件(dshClient + bundle),声明 `immediately` | 传输替换(Electron IPC 载体) | +| `dsh-client-connection` | wire 层 | 插件(dsh.client + bundle),声明 `immediately` | 传输替换(Electron IPC 载体) | | `dsh-client-runtime` | 会话对象层 + slots 服务 + store 引擎 | 插件,声明 `immediately` | 持续缩向纯会话对象层 | | `dsh-client-ui-theme` | 主题 token/服务 | 插件,声明 `immediately`,外加 `./styles/*` 源码通道 | Theme Registry(另行裁定) | | `dsh-client-i18n` | I18nService | 插件,声明 `immediately` | 按部署组合语言包 | @@ -132,7 +132,7 @@ wire 两侧跑着同一份治理实现;浏览器特有的表面只是一套模 | Rejected | One-line reason | |---|---| -| 两轴分类体系(entry × 到达),基础设施包不带 dshClient | 抹掉了 manifest 依赖边(inject 泄漏给组合方)、把插件形态拆成两种、让纯度门禁对一半插件失明 | +| 两轴分类体系(entry × 到达),基础设施包不带 dsh.client | 抹掉了 manifest 依赖边(inject 泄漏给组合方)、把插件形态拆成两种、让纯度门禁对一半插件失明 | | 继续把手写 loader 演化成治理器 | 重新实现 vendored Loader 已拥有的 entry/fiber 生命周期;HMR 将与 host 侧毫无共享骨架 | | 在浏览器复用 `@cordisjs/plugin-hmr` | 约 80% 在解决浏览器没有的问题(fs 监听、深度图着色、Node 的双缓存);只按形状抄用其重载骨架 | | 模块联邦(module federation) | 独立构建的远端 bundle 恰是 vite 联邦不支持的形态 | diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 1c73978183..897220cab4 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 9bf44e398da66ee286fc9bbc1496c002606d1606 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 23d9bc790c792438cb699952ee802e1ffa89de88 +2026-07-24-web-config-tree-boot-and-transport-layering.md: c00d0c544cfd04927d23eac53720cb969a44e044 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 4fe315bb7673ba219286b176123ccbbe08f02f0d diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 9bf44e398d..c00d0c544c 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Decision -**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations. +**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dsh.client` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations. **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 23d9bc790c..4fe315bb76 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。 +**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时(32 行)、`api-gateway` 行、`webserver` 行、`dsh.client` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。 **boot 胶水由两个类组成。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index da84a269ed..65091d39b4 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: e6a96ac62f69a3bd312f61cc920caa259d2dc5b0 -2026-07-24-web-session-model-selector.zh.md: 5a0245359d69ac6e59a20dc3276b9411c4b25e23 +2026-07-24-web-session-model-selector.md: 3f7dde359842a82d03218dc71ca0e3617ae6dbdf +2026-07-24-web-session-model-selector.zh.md: 474476966da8d4628578dc3e13f74f643bd6eaed diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index e6a96ac62f..3f7dde3598 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -18,7 +18,7 @@ The browser `ModelService` owns one `ModelDirectory` per live session. Its snaps `@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the exact catalog model name and effective reasoning label. When the current selection is absent from the groups, the trigger displays `Select model`, the model list marks no row active, and the Effort row stays absent; choosing a listed model assigns the complete selection through the shared selection path. The upward menu otherwise first offers Model and Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default. -The production browser roster is assembled from `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant. +The production browser roster is assembled from `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml`; the model feature is one `dsh.client` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index 5a0245359d..474476966d 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -18,7 +18,7 @@ Web Host 为每个新建或恢复的 Agent 安装 `ModelSelection`。如果会 `@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat,并在同一目录上提供 `/model`。其紧凑型触发器显示目录中精确模型的名称与生效的推理强度标签。当前选择不在分组中时,触发器显示 `Select model`,模型列表不标记任何活动行,Effort 行也保持隐藏;选择一个已列出的模型,会通过共享的选择路径赋值完整选择。除此情形外,向上展开的菜单会首先提供 Model 与 Effort;Model 可深入提供方分组,Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。 -生产环境的浏览器名册由 `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同组装;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。 +生产环境的浏览器名册由 `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同组装;模型功能对应其中一行 `dsh.client` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。 ## 考虑过的替代方案 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 29bfed3f19..09204ccfff 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 4b904f24e5755460b6c629e3d2fd43ffc1aefaaf -api-gateway.zh.md: 1e434cbb99450d241d6fde7e570ae1fadf5c0209 +api-gateway.md: 81cd80893d53212edc74cc85e3e05731fa05f411 +api-gateway.zh.md: 692cf825f619f71e86ae801e04246e9feb4a4c36 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 4b904f24e5..81cd80893d 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -151,7 +151,7 @@ pnpm run dsh -- web --dev pnpm run dev:web ``` -`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; `dev:web` watches only Client plugins with a `dshClient` declaration and rewrites their `lib/client.js`. It does not analyze Host decorators or generate Remote Client DTS. +`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; `dev:web` watches only Client plugins with a `dsh.client` declaration and rewrites their `lib/client.js`. It does not analyze Host decorators or generate Remote Client DTS. Changing only a Remote method's implementation body without changing its contract does not require regenerating the TypeRT files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, rerun the ordered lib build so the Host generates the strict contract before the Client compiles and bundles the new contribution: diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 1e434cbb99..692cf825f6 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -151,7 +151,7 @@ pnpm run dsh -- web --dev pnpm run dev:web ``` -`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 只监听带 `dshClient` 声明的 Client plugin 并重写其 `lib/client.js`,它不会分析 Host decorator,也不会生成 Remote Client DTS。 +`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 只监听带 `dsh.client` 声明的 Client plugin 并重写其 `lib/client.js`,它不会分析 Host decorator,也不会生成 Remote Client DTS。 只修改 Remote 方法实现体而不改变约定时,无需重新生成 TypeRT 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,重新执行有序 lib 构建,让 Host 先生成严格约定,再让 Client 编译并打包新的贡献: diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 85aee35af0..c102167aa7 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -437,7 +437,7 @@ flowchart LR | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | | `ctx.httpServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | -| `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | +| `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | Composes the __DSH_BOOT__ entry graph from an incremental dsh.client scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | | `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard. diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 26d61a6041..5bdff5df21 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md -adding-a-package.md: 9e82e6d00177768a6368d1cd9740afa585543171 -adding-a-package.zh.md: 072df33138a1ead20c497cebd8e4aa960c2d1fc8 +adding-a-package.md: dcd5fa66f3616c2c22930babd09cb3edab38e182 +adding-a-package.zh.md: c8769197e0b1db31348b7f2442dbcd636bf43cb2 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 9e82e6d001..dcd5fa66f3 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -34,7 +34,7 @@ In-package relative imports use explicit `.ts` specifiers in source (for example | `tsconfig.host.json` (Host package) or `tsconfig.client.json` (Client package) | add `{ "path": "./packages/<group>/<pkg>" }` to `references` — an ordinary package belongs to exactly one aggregate, never both. `api/remotes` uses a repository-specific split because the Host generates a contract that the Client consumes in a later phase; new packages must not copy it ([layout](../development.md#typescript-project-layout)) | | `knip.json` | only if the package has entrypoints that repository discovery does not already cover | -A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dshClient` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract. +A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dsh.client` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract. Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `.oxlintrc.json`, `scripts/check-workspace-constraints.ts`. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index 072df33138..c8769197e0 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -34,7 +34,7 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c | `tsconfig.host.json`(Host 包)或 `tsconfig.client.json`(Client 包) | 在 `references` 中添加 `{ "path": "./packages/<group>/<pkg>" }`——普通包恰好属于一个 aggregate,绝不两个都加。`api/remotes` 因 Host 生成约定与 Client 消费约定之间存在顺序依赖而使用仓库专属拆分,新增包不得仿照([布局](../development.md#typescript-project-layout)) | | `knip.json` | 仅当包有仓库发现机制尚未覆盖的入口时需要 | -`packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`);client 插件包还需在 package.json 声明 `dshClient`、导出 `./client`、调用共享 tsdown preset(`packages/client/tsdown.client.ts`)——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。 +`packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`);client 插件包还需在 package.json 声明 `dsh.client`、导出 `./client`、调用共享 tsdown preset(`packages/client/tsdown.client.ts`)——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。 以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts`、`tsdown.config.ts`、`.oxlintrc.json`、`scripts/check-workspace-constraints.ts`。 diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index de44d58882..486d414749 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -51,14 +51,13 @@ export interface DshProfileManifest { } /** - * The `dsh`-owned manifest section of a package.json. The nested key names - * the manifest kind: a bundle package declares `bundle`, a profile directory - * declares `profile`; nothing declares both. + * The profile-launcher slice of the `dsh`-owned package.json section. A + * manifest may declare both roles; other consumers own additional keys. */ export interface DshManifestSection { - /** Present on bundle packages only. */ + /** Bundle metadata consumed by the profile launcher. */ bundle?: DshBundleManifest - /** Present on profile manifests only. */ + /** Profile metadata consumed by the profile launcher. */ profile?: DshProfileManifest } diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 216eb13199..3e88c4d6ca 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -37,7 +37,7 @@ # ── web-only host rows, the transport layer, and the browser roster ───────── -# `dshClient` rows are the browser roster the modules node half scans into +# `dsh.client` rows are the browser roster the modules node half scans into # window.__DSH_BOOT__; the modules row is simultaneously a host row. - insert: - id: code-runtime @@ -101,9 +101,9 @@ printUrl: true surfaceContext: true - # ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── + # ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ── - # Dual-face: node half scans this very tree for dshClient rows, composes + # Dual-face: node half scans this very tree for dsh.client rows, composes # window.__DSH_BOOT__, serves /plugins/<id>/client.js; browser half is the # module table the shell kernel constructs before cordis exists (adopted # as a plugin entry by the kernel, never fetched). diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index f8f5e10856..f8bafa9a1f 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -91,9 +91,9 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a complete example; ui-sidebar/ui-question are minimal skeletons): -1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. -2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. -3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else. +1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. +2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. +3. **dsh.client manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else. 4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads. 5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources. diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index c2c8d4e942..cf6b949bf2 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/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/modules/README.md -README.md: 7b4c9b72e782dbdbb69d711ae7e022771afebace -README.zh.md: 6420f6324f38979af5428a9ad428f33525009f1f +README.md: a1d578850c2518a85dc32f048768b78caf5ffec4 +README.zh.md: 772a4870f7ef6730d9d3d4db434ed771d97984f0 diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index 7b4c9b72e7..a1d578850c 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -8,7 +8,7 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its external classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook). -The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures. +The Node half scans enabled Loader entries for web `dsh.client` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures. ## Model Experience diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index 6420f6324f..772a4870f7 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -8,7 +8,7 @@ 解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载外部 classic script + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR(热模块替换)钩子。 -Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。 +Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。 ## 模型体验 diff --git a/packages/client/modules/src/client/manifest.ts b/packages/client/modules/src/client/manifest.ts index edb437e247..50b2f8d985 100644 --- a/packages/client/modules/src/client/manifest.ts +++ b/packages/client/modules/src/client/manifest.ts @@ -44,7 +44,7 @@ declare module 'cordis' { * One composed client entry pushed by the host (a graph row). Wire * single source: the host node half (package root) produces this same shape. * `immediately` marks stage-one prefetch; `inject` is informational graph - * metadata (the authoritative edges live in each package's dshClient + * metadata (the authoritative edges live in each package's `dsh.client` * declaration and reach fibers through entry creation). */ export interface WebBootEntry { diff --git a/packages/client/runtime/tests/node-half.spec.ts b/packages/client/runtime/tests/node-half.spec.ts index efba1b0445..8a680eb036 100644 --- a/packages/client/runtime/tests/node-half.spec.ts +++ b/packages/client/runtime/tests/node-half.spec.ts @@ -1,4 +1,4 @@ -/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */ +/** Node half: the empty host apply (Loader governance + dsh.client discovery placeholder). */ import { describe, expect, it } from 'vitest' import { apply } from '../src/index.ts' diff --git a/packages/client/test-runtime/README.i18n.yaml b/packages/client/test-runtime/README.i18n.yaml index ee0488a20b..93538b2f40 100644 --- a/packages/client/test-runtime/README.i18n.yaml +++ b/packages/client/test-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/test-runtime/README.md -README.md: 455d6f564cea2cb8f88165a8bba1047c762d2fb0 -README.zh.md: 7c4bd0e552c71f55e3766a0c64580cc178461310 +README.md: d5c0797c37168578f08a08f3d5d57670d7973db0 +README.zh.md: 57854213c3a9ea28850665eb26d07bc824c017e8 diff --git a/packages/client/test-runtime/README.md b/packages/client/test-runtime/README.md index 455d6f564c..d5c0797c37 100644 --- a/packages/client/test-runtime/README.md +++ b/packages/client/test-runtime/README.md @@ -8,7 +8,7 @@ The doubles implement the same outward faces features receive through ctx (`Test Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `<div data-slot>` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) to keep `.snap` files structural and collapses `<svg>` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis. -Not part of the product plugin graph (no `dshClient`); feature packages depend on it in `devDependencies` only. +Not part of the product plugin graph (no `dsh.client`); feature packages depend on it in `devDependencies` only. ## Model Experience diff --git a/packages/client/test-runtime/README.zh.md b/packages/client/test-runtime/README.zh.md index 7c4bd0e552..57854213c3 100644 --- a/packages/client/test-runtime/README.zh.md +++ b/packages/client/test-runtime/README.zh.md @@ -8,7 +8,7 @@ 局部 DOM 快照:`declare(children)` 注册自动 frame,逐 key 的 `<div data-slot>` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图(container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`)保持 `.snap` 只含结构,并把 `<svg>` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)`;`mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。 -不属于产品插件图(无 `dshClient`);feature 包仅以 `devDependencies` 依赖之。 +不属于产品插件图(无 `dsh.client`);feature 包仅以 `devDependencies` 依赖之。 ## 模型体验 diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index d1a82406ef..5dea393047 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -5,7 +5,7 @@ * declaration, registration, scope, store, inject, rendering, updates, and * disposal without hand-building the machinery per suite. * - * Not part of the product plugin graph (no `dshClient`); feature packages + * Not part of the product plugin graph (no `dsh.client`); feature packages * depend on it in devDependencies only. It copies no SlotCore/renderer/store * machinery — everything mounts the production implementations. * @module @deepseek-ai/dsh-client-test-runtime diff --git a/packages/client/ui-command/src/index.ts b/packages/client/ui-command/src/index.ts index 29e446e339..1e3fc0f59b 100644 --- a/packages/client/ui-command/src/index.ts +++ b/packages/client/ui-command/src/index.ts @@ -1,7 +1,7 @@ /** * Command UI 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 + * via exports["./client"], discovered through the package.json dsh.client * declaration. The host command registry itself mounts separately * (bootHost + CommandService). */ diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index 012876cc2d..c6ea80fa21 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -1,7 +1,7 @@ /** * Deliverables 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 + * via exports["./client"], discovered through the package.json dsh.client * declaration. */ diff --git a/packages/client/ui-goal/src/index.ts b/packages/client/ui-goal/src/index.ts index 780cea398e..71f27a868d 100644 --- a/packages/client/ui-goal/src/index.ts +++ b/packages/client/ui-goal/src/index.ts @@ -2,7 +2,7 @@ * Goal surface 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. + * dsh.client declaration. */ /** Host plugin body — no host-side behavior for this surface plugin. */ diff --git a/packages/client/ui-model/src/index.ts b/packages/client/ui-model/src/index.ts index 83daadbf12..8495b8364e 100644 --- a/packages/client/ui-model/src/index.ts +++ b/packages/client/ui-model/src/index.ts @@ -2,7 +2,7 @@ * Model selection 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. + * dsh.client declaration. */ /** Host plugin body — no host-side behavior for this surface plugin. */ diff --git a/packages/client/ui-permission/src/index.ts b/packages/client/ui-permission/src/index.ts index 5c28cd69b2..77c22c7b76 100644 --- a/packages/client/ui-permission/src/index.ts +++ b/packages/client/ui-permission/src/index.ts @@ -2,7 +2,7 @@ * Permission surfaces plugin, node half. The empty apply exists so the plugin * appears in the host cordis.yml / Loader; the browser half ships the * new-session Settings row and current-session command picker through - * exports["./client"], discovered from the package.json dshClient declaration. + * exports["./client"], discovered from the package.json dsh.client declaration. */ /** Host plugin body — no host-side behavior for this surface plugin. */ diff --git a/packages/client/ui-plan/src/index.ts b/packages/client/ui-plan/src/index.ts index 8d63cb2102..a6bfe1acb1 100644 --- a/packages/client/ui-plan/src/index.ts +++ b/packages/client/ui-plan/src/index.ts @@ -1,7 +1,7 @@ /** * Plan control 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 + * via exports["./client"], discovered through the package.json dsh.client * declaration. Plan behavior itself (the /plan command, the plan projection * unit, the policy section) is owned by `@deepseek-ai/dsh-plan-mode`, * composed independently on the host roster. diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 815a62f24c..01c175954e 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -27,7 +27,7 @@ export type { /** * Required services (cordis fiber inject). The target slot is declared by * ui-sidebar's apply, whose activation order relative to this one is NOT - * constrained (dshClient.inject edges are informational); registration + * constrained (dsh.client.inject edges are informational); registration * depends on the slot through `slots.inject()`. */ export const inject = ['slots'] diff --git a/packages/client/ui-skill/src/index.ts b/packages/client/ui-skill/src/index.ts index e89fa95236..c623c4f198 100644 --- a/packages/client/ui-skill/src/index.ts +++ b/packages/client/ui-skill/src/index.ts @@ -2,7 +2,7 @@ * Skill reference 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. + * dsh.client declaration. */ /** Host plugin body — no host-side behavior for this source plugin. */ diff --git a/packages/client/ui-slash/src/index.ts b/packages/client/ui-slash/src/index.ts index 9b65ec1f57..acd7c77e48 100644 --- a/packages/client/ui-slash/src/index.ts +++ b/packages/client/ui-slash/src/index.ts @@ -1,7 +1,7 @@ /** * Slash trigger 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 + * via exports["./client"], discovered through the package.json dsh.client * declaration. */ diff --git a/packages/client/ui-subagent/src/index.ts b/packages/client/ui-subagent/src/index.ts index 825b860701..bf3ea99678 100644 --- a/packages/client/ui-subagent/src/index.ts +++ b/packages/client/ui-subagent/src/index.ts @@ -2,7 +2,7 @@ * Subagent reference 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. + * dsh.client declaration. */ /** Host plugin body — no host-side behavior for this source plugin. */ diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 3af6a26629..5f499c4336 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -37,7 +37,7 @@ const NS = 'workspace' /** * Required services (cordis fiber inject). The target slots are declared by * the ui-sidebar / ui-conversation applies, whose activation order relative - * to this one is NOT constrained: dshClient.inject edges are informational + * to this one is NOT constrained: dsh.client.inject edges are informational * (loading/prefetch metadata, never apply sequencing) and neither owner * provides a waitable service. apply therefore depends on each slot * declaration through `slots.inject()` instead of assuming order. diff --git a/packages/client/ui-workspace/src/index.ts b/packages/client/ui-workspace/src/index.ts index 2af6a1023b..bb551f7b1a 100644 --- a/packages/client/ui-workspace/src/index.ts +++ b/packages/client/ui-workspace/src/index.ts @@ -2,7 +2,7 @@ * Workspace picker plugin, node half. Pure UI plugin: the empty apply exists * so the plugin appears in the host cordis.yml / Loader (load and lifecycle * follow the host; the browser half ships via exports["./client"], discovered - * through the package.json dshClient declaration). + * through the package.json dsh.client declaration). */ /** Host plugin body — no host-side behavior for the workspace picker plugin. */ diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index aa875ca369..699bcae707 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -512,7 +512,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Client plugin graph host', mode: 'core', consumers: ['hmr'], - note: 'Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers.', + note: 'Composes the __DSH_BOOT__ entry graph from an incremental dsh.client scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers.', }, { key: 'workflows', From 717792b6310b2b361cfb5baab0b384e51b28625d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:39:26 +0800 Subject: [PATCH 125/229] refactor: nest client manifest metadata under dsh --- docs/capability-seams.i18n.yaml | 4 +-- docs/capability-seams.zh.md | 2 +- docs/subsystems/README.i18n.yaml | 4 +-- docs/subsystems/README.md | 2 +- docs/subsystems/README.zh.md | 2 +- docs/subsystems/client-modules.i18n.yaml | 4 +-- docs/subsystems/client-modules.md | 8 ++--- docs/subsystems/client-modules.zh.md | 8 ++--- packages/api/gateway/package.json | 16 +++++----- packages/api/remotes/package.json | 14 +++++---- packages/client/connection/package.json | 10 ++++--- packages/client/hmr/package.json | 10 ++++--- packages/client/locale/package.json | 16 +++++----- packages/client/modules/package.json | 12 ++++---- packages/client/modules/src/index.ts | 30 +++++++++++-------- .../client/modules/tests/node-half.spec.ts | 23 ++++++++++++-- packages/client/runtime/package.json | 16 +++++----- packages/client/ui-agent-preset/package.json | 20 +++++++------ packages/client/ui-agent-preset/src/index.ts | 2 +- packages/client/ui-command/package.json | 18 ++++++----- packages/client/ui-conversation/package.json | 18 ++++++----- packages/client/ui-deliverables/package.json | 16 +++++----- packages/client/ui-goal/package.json | 18 ++++++----- packages/client/ui-layout/package.json | 14 +++++---- packages/client/ui-model/package.json | 16 +++++----- packages/client/ui-models/package.json | 16 +++++----- packages/client/ui-permission/package.json | 18 ++++++----- packages/client/ui-plan/package.json | 16 +++++----- packages/client/ui-question/package.json | 14 +++++---- .../client/ui-settings-general/package.json | 18 ++++++----- packages/client/ui-settings/package.json | 14 +++++---- packages/client/ui-sidebar/package.json | 16 +++++----- packages/client/ui-skill/package.json | 18 ++++++----- packages/client/ui-slash/package.json | 14 +++++---- packages/client/ui-subagent/package.json | 20 +++++++------ packages/client/ui-theme/package.json | 18 ++++++----- packages/client/ui-tool/package.json | 16 +++++----- packages/client/ui-trajectory/package.json | 14 +++++---- packages/client/ui-workspace/package.json | 18 ++++++----- .../host/directory-picker-browse/package.json | 16 +++++----- .../host/directory-picker-native/package.json | 14 +++++---- .../tool-cordis/src/api-catalog.ts | 2 +- packages/typert/generator/README.i18n.yaml | 4 +-- packages/typert/generator/README.md | 2 +- packages/typert/generator/README.zh.md | 2 +- packages/typert/generator/src/analyzer.ts | 8 +++-- .../generator/tests/remote-model.spec.ts | 10 ++++--- .../typert/generator/tests/type-model.spec.ts | 6 ++-- packages/typert/registry/package.json | 10 ++++--- scripts/client-bundle-purity.spec.ts | 4 +-- scripts/dev-web.spec.ts | 23 ++++++++++++-- scripts/dev-web.ts | 14 +++++---- 52 files changed, 379 insertions(+), 269 deletions(-) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index c5cc60a014..d6c4832447 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 85aee35af0e3de4d7cdbb715bc60c832c023d5ac -capability-seams.zh.md: d3664b649f36b1788c6633497c95d5731ad4a401 +capability-seams.md: c102167aa76b9ba613b1b434cb0aa58765d26106 +capability-seams.zh.md: 7c4eab8d5a2d890bdf4513bb9acdc81f55414642 diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index d3664b649f..7c4eab8d5a 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -439,7 +439,7 @@ flowchart LR | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`、`directory-picker-browse` | `apiproxy` | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | | `ctx.httpServer` | `core` | `webserver` | - | `connection`、`modules`、`hmr` | - | 普通的 node:http 载体:具名路由注册表、索引转换 tap,以及静态 dist 回退;Web 传输插件注册自己的路由。 | -| `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | 通过增量 dshClient 扫描组合 __DSH_BOOT__ 入口图,提供插件组合包,并通知重建/图变更订阅方。 | +| `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | 通过增量 `dsh.client` 扫描组合 __DSH_BOOT__ 入口图,提供插件组合包,并通知重建/图变更订阅方。 | | `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow)、[`tool-ralph`](../packages/workflow/tool-ralph) | - | 每个上下文使用一个引擎,与 bash 相同,且没有具名提供方注册表;通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 | 维护模式:混合模式。服务从 Cordis 声明中发现;接口、实现和消费方角色在 `scripts/gen-doc-graphs.ts` 中分类,并设有完整性守卫。 diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 445d2b5a45..9e578f7886 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md -README.md: 7d66cfcf66ffb0bed9430892308934c1f10982f4 -README.zh.md: 90e2b28b15870387539500568bb85b525db63ef6 +README.md: fddbf460c8e9e7c6f9ed1d3375bdabe65947661f +README.zh.md: febc5a97426fef5ec4b2b80d9677957369994a3b diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 7d66cfcf66..fddbf460c8 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -45,7 +45,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [http-server.md](http-server.md) | the HTTP carrier: `WebRouteKind`/`WebRoute`, match order, the claimable fallback seat, index taps | | [storage.md](storage.md) | the storage subsystem: the backend contract (`StorageBackend`), `StorageForms`, `DomainSpec`/`Domain`, `domain/changed` | | [workspace.md](workspace.md) | the workspace registry: `Workspace`/`WorkspaceId`, registration and resolution, the session `cwd` relationship | -| [client-modules.md](client-modules.md) | the web plugin table: `dshClient` declarations, `WebBootGraph` wire composition, the bundle route and index tap | +| [client-modules.md](client-modules.md) | the web plugin table: `dsh.client` declarations, `WebBootGraph` wire composition, the bundle route and index tap | | [session-projection.md](session-projection.md) | the projection seam: `SessionProjectionMap`, the pure `ProjectionDefinition` unit, `ProjectionSnapshot`'s consistent cut, the change feed | | [telemetry.md](telemetry.md) | the outbound session-reporting capability seam: `TelemetryRecord`/`TelemetrySeverity`, the `TelemetryBackend` contract, and the `telemetry/record` redact waterfall | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 90e2b28b15..febc5a9742 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -45,7 +45,7 @@ | [http-server.md](http-server.md) | HTTP 载体:`WebRouteKind`/`WebRoute`、匹配顺序、可认领的回退席位、index 转换 | | [storage.md](storage.md) | 存储子系统:后端约定(`StorageBackend`)、`StorageForms`、`DomainSpec`/`Domain`、`domain/changed` | | [workspace.md](workspace.md) | 工作区注册表:`Workspace`/`WorkspaceId`、注册与解析、与会话 `cwd` 的关系 | -| [client-modules.md](client-modules.md) | Web 插件表:`dshClient` 声明、`WebBootGraph` 线上组合、bundle 路由与 index 转换 | +| [client-modules.md](client-modules.md) | Web 插件表:`dsh.client` 声明、`WebBootGraph` 线上组合、bundle 路由与 index 转换 | | [session-projection.md](session-projection.md) | 投影 seam:`SessionProjectionMap`、纯函数 `ProjectionDefinition` 单元、`ProjectionSnapshot` 的一致切面、变更馈送 | | [telemetry.md](telemetry.md) | 对外会话上报能力 seam:`TelemetryRecord`/`TelemetrySeverity`、`TelemetryBackend` 约定和 `telemetry/record` 脱敏 waterfall | diff --git a/docs/subsystems/client-modules.i18n.yaml b/docs/subsystems/client-modules.i18n.yaml index 8c320d18e6..71441e73fe 100644 --- a/docs/subsystems/client-modules.i18n.yaml +++ b/docs/subsystems/client-modules.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/client-modules.md -client-modules.md: 808fa62b2e5a4351b1dc98bbe4f3d2af63c9dd2b -client-modules.zh.md: dcab60bea7c54770cc5e6032afb39d6dd189d037 +client-modules.md: 14fa5b2ca94c109a8c6bbe2ce09bde73a4448769 +client-modules.zh.md: 7c4df1689dc8df27f7e394763e3bde4320d5c632 diff --git a/docs/subsystems/client-modules.md b/docs/subsystems/client-modules.md index 808fa62b2e..14fa5b2ca9 100644 --- a/docs/subsystems/client-modules.md +++ b/docs/subsystems/client-modules.md @@ -2,7 +2,7 @@ English | [中文](client-modules.zh.md) -The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModuleHost` (`ClientModuleHostService`). It scans the host Loader's entries for `dshClient` packages, composes the `window.__DSH_BOOT__` entry graph, serves each bundle at `/plugins/<id>/client.js`, and taps the index render to inject the boot manifest — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [http-server.md](http-server.md) supplies the prefix route and index tap this service registers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here. +The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModuleHost` (`ClientModuleHostService`). It scans the host Loader's entries for packages declaring `dsh.client`, composes the `window.__DSH_BOOT__` entry graph, serves each bundle at `/plugins/<id>/client.js`, and taps the index render to inject the boot manifest — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [http-server.md](http-server.md) supplies the prefix route and index tap this service registers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here. Source: [`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts) @@ -15,7 +15,7 @@ The graph is the wire single source between the Node and browser halves: the hos * One composed client entry pushed by the host (a graph row). Wire * single source: the host node half (package root) produces this same shape. * `immediately` marks stage-one prefetch; `inject` is informational graph - * metadata (the authoritative edges live in each package's dshClient + * metadata (the authoritative edges live in each package's `dsh.client` * declaration and reach fibers through entry creation). */ interface WebBootEntry { @@ -46,7 +46,7 @@ Each row's `rev` is the bundle's content hash and rides the URL as a cache-busti ## The scan -A package joins the table by declaring `dshClient` (`platform: 'web'`, optional `inject` edges, optional `immediately`) in its package.json and exporting its built bundle at `exports["./client"]`. Package resolution anchors at the config tree's `ctx.baseUrl` — the cordis.yml directory, whose package declares every composed plugin as a dependency — and construction throws when that anchor is unset. +A package joins the table by declaring `dsh.client` (`platform: 'web'`, optional `inject` edges, optional `immediately`) in its package.json and exporting its built bundle at `exports["./client"]`. Package resolution anchors at the config tree's `ctx.baseUrl` — the cordis.yml directory, whose package declares every composed plugin as a dependency — and construction throws when that anchor is unset. Scanning is incremental per package; there is no full-rescan code path. Every cordis `internal/plugin` emission (fiber construction or disposal) marks the fiber's entry name dirty, and a microtask flush reconciles each dirty name against the live loader entries. The activation pass seeds the same dirty set with all current entries and flushes synchronously, so first scan and steady state share one implementation — with opposite failure postures. At activation, a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud `AggregateError` listing every broken package: the fiber FAILS and the boot's fail-loud sweep reports it. In steady state, a broken package logs a warning and must not poison the others. @@ -74,7 +74,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.clientModuleHost` — `ClientModuleHostService` -The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it). +The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it). ```ts cordis-catalog /** diff --git a/docs/subsystems/client-modules.zh.md b/docs/subsystems/client-modules.zh.md index dcab60bea7..7c4df1689d 100644 --- a/docs/subsystems/client-modules.zh.md +++ b/docs/subsystems/client-modules.zh.md @@ -2,7 +2,7 @@ [English](client-modules.md) | 中文 -Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModuleHost`(`ClientModuleHostService`)形式提供。它扫描宿主 Loader 的 entry 找出 `dshClient` 包(package),组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins/<id>/client.js` 提供各个 bundle,并经 index 转换(index tap)注入启动 manifest(元数据清单)——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop(智能体循环)主干,并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[http-server.md](http-server.md) 所述的载体提供本服务注册的前缀路由与 index 转换。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.md)中,不在本页。 +Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModuleHost`(`ClientModuleHostService`)形式提供。它扫描宿主 Loader 的 entry,找出声明了 `dsh.client` 的包(package),组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins/<id>/client.js` 提供各个 bundle,并经 index 转换(index tap)注入启动 manifest(元数据清单)——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop(智能体循环)主干,并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[http-server.md](http-server.md) 所述的载体提供本服务注册的前缀路由与 index 转换。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.md)中,不在本页。 源码:[`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts) @@ -15,7 +15,7 @@ Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client * One composed client entry pushed by the host (a graph row). Wire * single source: the host node half (package root) produces this same shape. * `immediately` marks stage-one prefetch; `inject` is informational graph - * metadata (the authoritative edges live in each package's dshClient + * metadata (the authoritative edges live in each package's `dsh.client` * declaration and reach fibers through entry creation). */ interface WebBootEntry { @@ -46,7 +46,7 @@ interface WebBootGraph { ## 扫描 -包加入这张表的方式,是在自己的 package.json 中声明 `dshClient`(`platform: 'web'`、可选的 `inject` 边、可选的 `immediately`),并在 `exports["./client"]` 导出构建好的 bundle。包解析锚定在配置树的 `ctx.baseUrl`——即 cordis.yml 所在目录,该目录的包把每个被组合的插件声明为依赖——这一锚点未设置时,构造即抛错。 +包加入这张表的方式,是在自己的 package.json 中声明 `dsh.client`(`platform: 'web'`、可选的 `inject` 边、可选的 `immediately`),并在 `exports["./client"]` 导出构建好的 bundle。包解析锚定在配置树的 `ctx.baseUrl`——即 cordis.yml 所在目录,该目录的包把每个被组合的插件声明为依赖——这一锚点未设置时,构造即抛错。 扫描是单包增量的;不存在全量重扫代码路径。fiber 构造或 dispose(资源释放)时的每次 cordis `internal/plugin` 发射都把该 fiber 的 entry 名标脏,一次微任务 flush 把每个脏名与实时 loader entry 对账。激活趟以全部当前 entry 灌入同一个脏集合并同步 flush,因此初扫与稳态共享一条实现——但失败姿态相反。激活时,已加载 entry 中的畸形声明或缺失 bundle 会聚合为一个大声的 `AggregateError`,列出每个损坏的包:该 fiber 进入 FAILED,由启动的大声失败 sweep 上报。稳态下,损坏的包只记录一条警告,且不得殃及其他包。 @@ -74,7 +74,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.clientModuleHost` — `ClientModuleHostService` -The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it). +The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it). ```ts cordis-catalog /** diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index fa351d84bf..0b76b607cd 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -26,13 +26,15 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-typert-registry", - "@deepseek-ai/dsh-client-connection" - ], - "platform": "web", - "immediately": true + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-typert-registry", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web", + "immediately": true + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 0a1e3ec71d..3112a1e7c6 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -22,12 +22,14 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-api-gateway" - ], - "platform": "web", - "immediately": true + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-gateway" + ], + "platform": "web", + "immediately": true + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index baafbd4456..1433c59655 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -22,10 +22,12 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [], - "platform": "web", - "immediately": true + "dsh": { + "client": { + "inject": [], + "platform": "web", + "immediately": true + } }, "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 7f86a68b10..04e94c24bd 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -22,10 +22,12 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [], - "platform": "web", - "immediately": true + "dsh": { + "client": { + "inject": [], + "platform": "web", + "immediately": true + } }, "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index cfadff76b8..0814c86b8d 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -22,13 +22,15 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-runtime" - ], - "platform": "web", - "immediately": true + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-runtime" + ], + "platform": "web", + "immediately": true + } }, "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 2283853a7d..906ef783ed 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-modules", - "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dshClient scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", + "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, "type": "module", @@ -22,10 +22,12 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "platform": "web", - "inject": [], - "immediately": true + "dsh": { + "client": { + "platform": "web", + "inject": [], + "immediately": true + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index d53be39e0d..a0f932fa84 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -1,6 +1,6 @@ /** - * Node half of the client module system (dshClient dual-face package): scans - * the host Loader's entries for `dshClient` packages, composes the + * Node half of the client module system (`dsh.client` dual-face package): scans + * the host Loader's entries for packages declaring `dsh.client`, composes the * `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry} * in `./client/manifest.ts`), serves `/plugins/<id>/client.js` and its source * map, taps the index render to inject the boot manifest, and provides the @@ -43,7 +43,7 @@ declare module 'cordis' { } } -/** package.json `dshClient` declaration fields, validated one by one after reading the file. */ +/** package.json `dsh.client` declaration fields, validated one by one after reading the file. */ interface DshClientDeclaration { inject?: string[] platform: string @@ -51,7 +51,7 @@ interface DshClientDeclaration { immediately?: boolean } -/** Resolved package metadata for one dshClient package (cached per name, never expires). */ +/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */ interface PkgMeta { clientPath: string inject?: string[] @@ -105,21 +105,21 @@ interface WebPluginRecord { clientPath: string } -/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */ +/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */ function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined { if (value === undefined) return undefined if (typeof value !== 'object' || value === null) { - throw new Error(`client-modules: ${pkgName} has a non-object dshClient declaration`) + throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`) } const decl = value as Record<string, unknown> if (typeof decl.platform !== 'string') { - throw new Error(`client-modules: ${pkgName} dshClient.platform must be a string`) + throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`) } if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) { - throw new Error(`client-modules: ${pkgName} dshClient.inject must be a string array`) + throw new Error(`client-modules: ${pkgName} dsh.client.inject must be a string array`) } if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') { - throw new Error(`client-modules: ${pkgName} dshClient.immediately must be a boolean`) + throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`) } return { platform: decl.platform, @@ -175,7 +175,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string { } /** - * The web plugin table service: incremental dshClient scan + wire composition + * The web plugin table service: incremental `dsh.client` scan + wire composition * + bundle route + index tap. Construction runs the activation scan * synchronously — a malformed declaration or missing bundle among the * already-loaded entries aggregates into one loud throw (FAILED fiber; the @@ -186,7 +186,7 @@ export class ClientModuleHostService extends Service { private readonly table = new Map<string, WebPluginRecord>() // Negative verdicts (unresolvable specifier — builtins like cordis:include, - // subpath rows — or a package without a web dshClient declaration) are + // subpath rows — or a package without a web `dsh.client` declaration) are // cached as null and never expire: plugin-set changes take effect on restart. private readonly pkgMeta = new Map<string, PkgMeta | null>() private readonly rebuildListeners = new Set<(id: string, rev: string) => void>() @@ -342,14 +342,18 @@ export class ClientModuleHostService extends Service { return null } const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown> - const decl = parseDshClient(pkgName, pkg.dshClient) + const dsh = pkg.dsh + const decl = parseDshClient( + pkgName, + dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined, + ) if (decl === undefined || decl.platform !== 'web') { this.pkgMeta.set(pkgName, null) return null } const clientRel = clientExportOf(pkgName, pkg.exports) if (clientRel === undefined) { - throw new Error(`client-modules: ${pkgName} declares dshClient but exports no "./client" bundle`) + throw new Error(`client-modules: ${pkgName} declares dsh.client but exports no "./client" bundle`) } const meta: PkgMeta = { clientPath: join(dirname(pkgPath), clientRel), diff --git a/packages/client/modules/tests/node-half.spec.ts b/packages/client/modules/tests/node-half.spec.ts index f95ff952c9..c2c865fd12 100644 --- a/packages/client/modules/tests/node-half.spec.ts +++ b/packages/client/modules/tests/node-half.spec.ts @@ -17,8 +17,11 @@ afterEach(() => { root = undefined }) -/** Create a resolvable dshClient package whose client export points at the returned path. */ -function writePackage(packageName: string): string { +/** Create a resolvable package whose client export points at the returned path. */ +function writePackage( + packageName: string, + metadata: Record<string, unknown> = { dsh: { client: { platform: 'web' } } }, +): string { root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-'))) const pkgRoot = join(root, 'node_modules', ...packageName.split('/')) const clientPath = join(pkgRoot, 'lib', 'client.js') @@ -29,7 +32,7 @@ function writePackage(packageName: string): string { './client': './lib/client.js', './package.json': './package.json', }, - dshClient: { platform: 'web' }, + ...metadata, })) return clientPath } @@ -66,6 +69,20 @@ function construct(packageNames: string[]): ClientModuleHostService { } describe('client bundle activation', () => { + it('allows sibling dsh roles', () => { + const currentName = '@fixture/current-client-field' + const clientPath = writePackage(currentName, { + dsh: { + bundle: { patch: './cordis.patch.yml' }, + client: { platform: 'web' }, + profile: { bundles: [] }, + }, + }) + mkdirSync(dirname(clientPath), { recursive: true }) + writeFileSync(clientPath, 'module.exports = {}\n') + expect(construct([currentName]).graph().entries.map(entry => entry.id)).toEqual([currentName]) + }) + it('groups missing bundles under one source-build instruction with a package/path list', () => { const firstName = '@fixture/missing-first' const secondName = '@fixture/missing-second' diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index bb24c62a32..9f922ed956 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -22,13 +22,15 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-typert-registry" - ], - "platform": "web", - "immediately": true + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-typert-registry" + ], + "platform": "web", + "immediately": true + } }, "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 6b42c14ec2..2f682d3de5 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -22,15 +22,17 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-settings" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-settings" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-agent-preset/src/index.ts b/packages/client/ui-agent-preset/src/index.ts index c145962f1d..61f59ec50d 100644 --- a/packages/client/ui-agent-preset/src/index.ts +++ b/packages/client/ui-agent-preset/src/index.ts @@ -2,7 +2,7 @@ * Agent-preset surface plugin, node half. The empty apply exists so the plugin * appears in the host cordis.yml / Loader; the browser half ships the * General-settings row through exports["./client"], discovered from the - * package.json dshClient declaration. + * package.json dsh.client declaration. */ /** Host plugin body — no host-side behavior for this surface plugin. */ diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json index 30f23bcbf2..df3c88a678 100644 --- a/packages/client/ui-command/package.json +++ b/packages/client/ui-command/package.json @@ -22,14 +22,16 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-slash", - "@deepseek-ai/dsh-client-ui-conversation" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-slash", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 51aef9470b..5dda7ef637 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -22,14 +22,16 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-layout" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-layout" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 0b5318499b..07e8a1bc28 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -22,13 +22,15 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-conversation" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 63410707a0..9e8b6865f9 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -22,14 +22,16 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-api-remotes", - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-conversation" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 1a6e2785b6..ba41bc9dcb 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -22,12 +22,14 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-theme" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-theme" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index 6be5dcbc43..6b6ed328fc 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -22,13 +22,15 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-command" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-command" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-models/package.json b/packages/client/ui-models/package.json index abe8dbc0f5..dc02a09aa6 100644 --- a/packages/client/ui-models/package.json +++ b/packages/client/ui-models/package.json @@ -22,13 +22,15 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-client-locale" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index 55d896dbec..4c90851cf7 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -22,14 +22,16 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-command" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-command" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 2ded06429c..c9aa3fd0f1 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -22,13 +22,15 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-conversation" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index 7e129c9731..874a634f7c 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -22,12 +22,14 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-conversation" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index a3e8973280..95166e6d66 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -22,14 +22,16 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-connection" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 6fa2fdc8cb..9383ca0205 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -22,12 +22,14 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-sidebar" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-sidebar" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index dfbeefbe44..d40bcabcdb 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -22,13 +22,15 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-layout", - "@deepseek-ai/dsh-client-locale" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 420b1c4322..68a2dc2667 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -22,14 +22,16 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-tool", - "@deepseek-ai/dsh-client-ui-slash" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-tool", + "@deepseek-ai/dsh-client-ui-slash" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-slash/package.json b/packages/client/ui-slash/package.json index 7562a536e3..01e48dcdf6 100644 --- a/packages/client/ui-slash/package.json +++ b/packages/client/ui-slash/package.json @@ -22,12 +22,14 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-locale" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 026b00196b..43a9c35a42 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -22,15 +22,17 @@ "./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", - "@deepseek-ai/dsh-client-ui-slash" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-primitives", + "@deepseek-ai/dsh-client-ui-slash" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index a4f9a78d37..dbc65c11e9 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -23,14 +23,16 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-locale" - ], - "platform": "web", - "immediately": true + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web", + "immediately": true + } }, "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 965a084cd2..c40b09abdb 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -22,13 +22,15 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-conversation" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index f56a9825ce..9ed4b550c1 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -22,12 +22,14 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-conversation" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 71148df7b6..197369cd48 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -22,14 +22,16 @@ "./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-sidebar" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-sidebar" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 58f984159f..85256ef7dc 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -56,12 +56,14 @@ "cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-workspace", - "@deepseek-ai/dsh-client-locale" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-workspace", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + } } } diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 2c75ba0a5e..c8e4e4003b 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -57,11 +57,13 @@ "react": "^18.2.0", "tsx": "^4.19.2" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-workspace" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-workspace" + ], + "platform": "web" + } } } diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 69fac20609..01631bb22c 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -280,7 +280,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'clientModuleHost', - summary: 'The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap.', + summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap.', methods: [ { signature: 'graph(): WebBootGraph', diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index 55fa47ebc9..2bbbb27f46 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/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/typert/generator/README.md -README.md: 38030c2b7e07c70ab79001086640b6581943dbd9 -README.zh.md: 0cf0a365785775a9461e9744d29a0da482e54d14 +README.md: ac2610620af14a30143e95921273a8c254b50a17 +README.zh.md: a1fa777493578533c93db6cd3354b89c8757d837 diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index 38030c2b7e..ac2610620a 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) TypeScript project analyzer and model-driven Typert generator. It converts the developer-authored source type tree into compiler-independent `FaceModel` and `TypeGraph` data before any artifact is rendered. Static analysis can consume that model without Cordis; emitters never receive TypeScript AST or checker objects. -The analyzer can use independent `ts.Program` instances seeded from `tsconfig.host.json` or `tsconfig.client.json`. Direct project references establish compiler-face membership, while package subpaths establish TypeRT runtime-face contributions: an ordinary single-project `dshClient` package may contribute both Host and Client runtime models, and only a split project explicitly referenced through `tsconfig.host.json` or `tsconfig.client.json` is restricted to that corresponding face. `package.json#exports` establishes every cross-package public boundary, and source imports or re-exports are the only allowed cross-face edges. Types owned by NPM dependencies, including global declarations from `@types` packages, remain `external` references instead of being expanded. +The analyzer can use independent `ts.Program` instances seeded from `tsconfig.host.json` or `tsconfig.client.json`. Direct project references establish compiler-face membership, while package subpaths establish TypeRT runtime-face contributions: an ordinary single-project package declaring `dsh.client` may contribute both Host and Client runtime models, and only a split project explicitly referenced through `tsconfig.host.json` or `tsconfig.client.json` is restricted to that corresponding face. `package.json#exports` establishes every cross-package public boundary, and source imports or re-exports are the only allowed cross-face edges. Types owned by NPM dependencies, including global declarations from `@types` packages, remain `external` references instead of being expanded. ## Analysis Model diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index 0cf0a36578..a1fa777493 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -4,7 +4,7 @@ TypeScript 项目分析器和模型驱动的 Typert 生成器。在生成任何产物之前,它会先将开发者编写的源类型树转换为独立于编译器的 `FaceModel` 和 `TypeGraph` 数据。静态分析无需 Cordis 即可消费该模型;各产物生成组件均不会接收 TypeScript 抽象语法树(AST)或类型检查器对象。 -分析器可以分别使用由 `tsconfig.host.json` 或 `tsconfig.client.json` 初始化的独立 `ts.Program`。直接项目引用确定编译器 face 的成员归属,而包子路径确定 TypeRT 运行时 face 的贡献:带 `dshClient` 的普通单项目包可以同时贡献 Host 与 Client 运行时模型;只有通过 `tsconfig.host.json` 或 `tsconfig.client.json` 显式引用的拆分项目,才会被限制在相应 face。`package.json#exports` 确定所有跨包公开边界,跨 face 的边只能来自源码导入或重新导出。NPM 依赖拥有的类型(包括 `@types` 包中的全局声明)继续以 `external` 引用表示,不会被展开。 +分析器可以分别使用由 `tsconfig.host.json` 或 `tsconfig.client.json` 初始化的独立 `ts.Program`。直接项目引用确定编译器 face 的成员归属,而包子路径确定 TypeRT 运行时 face 的贡献:声明 `dsh.client` 的普通单项目包可以同时贡献 Host 与 Client 运行时模型;只有通过 `tsconfig.host.json` 或 `tsconfig.client.json` 显式引用的拆分项目,才会被限制在相应 face。`package.json#exports` 确定所有跨包公开边界,跨 face 的边只能来自源码导入或重新导出。NPM 依赖拥有的类型(包括 `@types` 包中的全局声明)继续以 `external` 引用表示,不会被展开。 ## 分析模型 diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 9f4d3e68e5..e5ade888cf 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -2564,8 +2564,12 @@ function hasPackageSurface(model: PackageModel): boolean { } function isDualFacePackage(manifest: Record<string, unknown>): boolean { - return manifest.dshClient !== null - && typeof manifest.dshClient === 'object' + const dsh = manifest.dsh + const client = dsh !== null && typeof dsh === 'object' + ? (dsh as Record<string, unknown>).client + : undefined + return client !== null + && typeof client === 'object' && clientExportSubpaths(manifest).length > 0 } diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index a41f0c0cd0..3faef9c8be 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -309,11 +309,11 @@ export interface RemainingSchema { const root = copyFixture() const manifestPath = join(root, 'packages/remote/package.json') const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { - dshClient?: object + dsh?: { client?: object } exports: Record<string, unknown> files: string[] } - manifest.dshClient = {} + manifest.dsh = { client: {} } manifest.exports['./client'] = './src/client.ts' manifest.exports['./client/typert'] = { types: './lib/typert.client.d.ts', @@ -332,8 +332,10 @@ export interface ClientMarker { } `) - expect(new WorkspaceTypertGenerator(root).generate().map(artifact => artifact.face)) - .toEqual(['host', 'client']) + const artifacts = new WorkspaceTypertGenerator(root).generate() + expect(artifacts.map(artifact => artifact.face)).toEqual(['host', 'client']) + expect(artifacts.find(artifact => artifact.face === 'host')?.dts).not.toContain('ClientMarker') + expect(artifacts.find(artifact => artifact.face === 'client')?.dts).toContain('ClientMarker') }) it.each([ diff --git a/packages/typert/generator/tests/type-model.spec.ts b/packages/typert/generator/tests/type-model.spec.ts index 13771d546e..81e7f0b981 100644 --- a/packages/typert/generator/tests/type-model.spec.ts +++ b/packages/typert/generator/tests/type-model.spec.ts @@ -869,7 +869,7 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { .toEqual(['@fixture/host']) }) - it('keeps both runtime faces for an ordinary dshClient project', () => { + it('keeps both runtime faces for an ordinary dsh.client project', () => { const root = copyFixture('typert-dual-runtime-') configureDualRuntimeClient(root, false) @@ -1227,10 +1227,10 @@ function configureDualRuntimeClient(root: string, splitProjects: boolean): void const packageRoot = join(root, 'packages/client') const manifestPath = join(packageRoot, 'package.json') const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { - dshClient?: object + dsh?: { client?: object } exports: Record<string, unknown> } - manifest.dshClient = {} + manifest.dsh = { client: {} } manifest.exports['./client'] = { types: './lib/types/client.d.ts', default: './lib/client.js', diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index e912808293..f094a0c11c 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -26,10 +26,12 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [], - "platform": "web", - "immediately": true + "dsh": { + "client": { + "inject": [], + "platform": "web", + "immediately": true + } }, "scripts": { "bundle": "tsdown", diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index d43c87f08b..4077c806f3 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -97,9 +97,9 @@ describe('client bundle purity gate', () => { it('carries exactly one documented temporary exemption: runtime/client (store engine pending rehoming)', () => { expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull() - const dshClientChannels = CLIENT_EXTERNALS.filter( + const clientChannels = CLIENT_EXTERNALS.filter( entry => entry.startsWith('@deepseek-ai/') && entry.endsWith('/client')) - expect(dshClientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client']) + expect(clientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client']) }) }) diff --git a/scripts/dev-web.spec.ts b/scripts/dev-web.spec.ts index 71576caf9e..293c6e94f0 100644 --- a/scripts/dev-web.spec.ts +++ b/scripts/dev-web.spec.ts @@ -1,9 +1,28 @@ -import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it } from 'vitest' import type { TsdownBundle } from 'tsdown' -import { watchClientPlugins } from './dev-web.ts' +import { discoverPluginDirs, watchClientPlugins } from './dev-web.ts' + +it('discovers dsh.client packages with sibling roles', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-discovery-')) + try { + const current = join(root, 'packages', 'client', 'current') + await mkdir(current, { recursive: true }) + await writeFile(join(current, 'package.json'), JSON.stringify({ + dsh: { + bundle: { patch: './cordis.patch.yml' }, + client: { platform: 'web' }, + profile: { bundles: [] }, + }, + })) + + expect(discoverPluginDirs(root)).toEqual(['packages/client/current']) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) it('rebuilds a client-plugin bundle after its source changes', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-watch-')) diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index 294e38002b..ea409a89d6 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -1,5 +1,5 @@ /** - * Watch-build for client-plugin HMR: runs every dshClient plugin package + * Watch-build for client-plugin HMR: runs every `dsh.client` plugin package * through the tsdown JS API in watch mode. Reload signaling is not this * script's business — the host webserver stat-polls the bundles it serves and * broadcasts `rebuilt` frames itself (`dsh web --dev`), so any process that @@ -27,7 +27,7 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url)) /** * Discover the watch workspace by declaration: every packages/<group>/<name> - * whose package.json carries `dshClient` with platform "web" is a client + * whose package.json carries `dsh.client` with platform "web" is a client * plugin bundle emitter. Scanned once at startup — a package added while * watching means restarting this script. * @param root - repository root containing the grouped package directories. @@ -36,8 +36,10 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url)) export function discoverPluginDirs(root = repoRoot): string[] { const dirs: string[] = [] for (const manifestPath of globSync('packages/*/*/package.json', { cwd: root }).sort()) { - const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } } - if (manifest.dshClient?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/')) + const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as { + dsh?: { client?: { platform?: unknown } } + } + if (manifest.dsh?.client?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/')) } return dirs } @@ -88,7 +90,7 @@ const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(re if (isMain) { const pluginDirs = discoverPluginDirs() if (pluginDirs.length === 0) { - console.error('dev-web: no dshClient (platform "web") packages found under packages/') + console.error('dev-web: no dsh.client (platform "web") packages found under packages/') process.exit(1) } @@ -106,7 +108,7 @@ if (isMain) { await watchClientPlugins(repoRoot, pluginDirs, pollInterval) console.log( - `dev-web: watching ${String(pluginDirs.length)} dshClient plugin packages` + `dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages` + `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${pluginDirs.join('\n ')}`, ) } 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 126/229] =?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 ( <span className={css.label} title={text?.description ?? t('headerHint')}> - <IconThinkOutline16 className={css.icon} /> + <IconAgentPresetOutline16 className={css.icon} /> {text?.name ?? preset} </span> ) 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<void> /** Stage one preset for the next session. */ select: (id: string) => Promise<void> + /** 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 + ? ( + <span className={css.introText}> + {Array.from(label).map((character, index) => ( + <span + key={index} + className={css.introChar} + style={{ animationDelay: `${INTRO_TEXT_DELAY_MS + index * INTRO_CHAR_STAGGER_MS}ms` }} + > + {character} + </span> + ))} + </span> + ) + : label return ( <Menu @@ -95,8 +144,8 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr disabled={state.busy} onClick={() => { setOpen(value => !value) }} > - <IconThinkOutline16 className={css.seatIcon} /> - {chosenText?.name ?? state.current} + <IconAgentPresetOutline16 className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} /> + {shownLabel} <IconChevronDownOutline14 className={css.chevron} /> </button> )} 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') + ? ( + <button + type="button" + className={css.creatorButton} + disabled={!state.authorable} + title={state.authorable ? undefined : t('duplicateUnavailable')} + onClick={() => { + props.startCreatorDraft?.() + props.close() + }} + > + {/* Same glyph as the Models page's add affordances. */} + <IconPlusOutline16 size={14} /> + {t('creatorDraft')} + </button> + ) + : null + return ( <div className={css.section}> <h2 className={css.title}>{t('nav')}</h2> @@ -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 ( <section key={trust} className={css.group}> <h3 className={css.groupHead}>{heading}</h3> - <ul className={css.cards}> - {group.map(({ row, text }) => ( - <li - key={row.id} - className={row.broken !== undefined - ? `${css.card} ${css.cardBroken}` - : row.isDefault ? `${css.card} ${css.cardActive}` : css.card} - > - {/* The card body IS the control: picking a preset is the + {group.length === 0 ? null : ( + <ul className={css.cards}> + {group.map(({ row, text }) => ( + <li + key={row.id} + className={row.broken !== undefined + ? `${css.card} ${css.cardBroken}` + : row.isDefault ? `${css.card} ${css.cardActive}` : css.card} + > + {/* 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. */} - <button - type="button" - className={css.cardMain} - aria-pressed={row.isDefault} - disabled={row.isDefault || row.broken !== undefined} - // Without this the name is the whole card read aloud — - // title, badge, description, id. - aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`} - title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))} - onClick={() => { void props.makeDefault(row.id) }} - > - <span className={css.cardHead}> - <span className={css.cardName}>{text.name}</span> - {row.broken !== undefined - ? <span className={css.brokenBadge}>{t('brokenBadge')}</span> - : null} - <span className={css.badge}> - {row.trust === 'user' ? t('userTrust') : t('builtIn')} + <button + type="button" + className={css.cardMain} + aria-pressed={row.isDefault} + disabled={row.isDefault || row.broken !== undefined} + // Without this the name is the whole card read aloud — + // title, badge, description, id. + aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`} + title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))} + onClick={() => { void props.makeDefault(row.id) }} + > + <span className={css.cardHead}> + <span className={css.cardName}>{text.name}</span> + {row.broken !== undefined + ? <span className={css.brokenBadge}>{t('brokenBadge')}</span> + : null} + <span className={css.badge}> + {row.trust === 'user' ? t('userTrust') : t('builtIn')} + </span> + {row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null} </span> - {row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null} - </span> - <span className={css.cardDesc}>{text.description ?? t('noDescription')}</span> - {row.broken === undefined - ? null - : <span className={css.cardBrokenReason} role="alert">{row.broken}</span>} - <code className={css.cardId}>{row.id}</code> - </button> - <div className={css.cardFoot}> - {/* Shipped presets are the compositions a copy starts + <span className={css.cardDesc}>{text.description ?? t('noDescription')}</span> + {row.broken === undefined + ? null + : <span className={css.cardBrokenReason} role="alert">{row.broken}</span>} + <code className={css.cardId}>{row.id}</code> + </button> + <div className={css.cardFoot}> + {/* 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 + ? ( + <button + type="button" + className={css.iconButton} + data-tip={t('view')} + aria-label={`${t('view')}: ${text.name}`} + onClick={() => { void props.view(row.id) }} + > + <IconBrowseOutline16 /> + </button> + ) + : null + : ( <button type="button" className={css.iconButton} - data-tip={t('view')} - aria-label={`${t('view')}: ${text.name}`} - onClick={() => { void props.view(row.id) }} + data-tip={state.hasDocument ? t('openLocation') : t('showLocation')} + aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`} + onClick={() => { void props.openLocation(row.id) }} > - <IconBrowseOutline16 /> + <IconFolderOpenOutline16 /> + </button> + )} + <button + type="button" + className={css.iconButton} + disabled={!state.authorable || row.broken !== undefined} + data-tip={row.broken !== undefined + ? t('brokenNoCopy') + : state.authorable ? t('duplicate') : t('duplicateUnavailable')} + aria-label={`${t('duplicate')}: ${text.name}`} + onClick={() => { props.beginCopy(row.id) }} + > + <IconCopyOutline16 /> + </button> + {row.trust === 'user' + ? ( + <button + type="button" + className={`${css.iconButton} ${css.iconDanger}`} + data-tip={t('delete')} + aria-label={`${t('delete')}: ${text.name}`} + onClick={() => { props.confirmDelete(row.id) }} + > + <IconTrashOutline16 /> </button> ) - : null + : null} + </div> + {state.revealedPaths[row.id] === undefined + ? null : ( - <button - type="button" - className={css.iconButton} - data-tip={state.hasDocument ? t('openLocation') : t('showLocation')} - aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`} - onClick={() => { void props.openLocation(row.id) }} - > - <IconFolderOpenOutline16 /> - </button> + <p className={css.revealedPath}> + <span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span> + <code>{state.revealedPaths[row.id]}</code> + </p> )} - <button - type="button" - className={css.iconButton} - disabled={!state.authorable || row.broken !== undefined} - data-tip={row.broken !== undefined - ? t('brokenNoCopy') - : state.authorable ? t('duplicate') : t('duplicateUnavailable')} - aria-label={`${t('duplicate')}: ${text.name}`} - onClick={() => { props.beginCopy(row.id) }} - > - <IconCopyOutline16 /> - </button> - {row.trust === 'user' - ? ( - <button - type="button" - className={`${css.iconButton} ${css.iconDanger}`} - data-tip={t('delete')} - aria-label={`${t('delete')}: ${text.name}`} - onClick={() => { props.confirmDelete(row.id) }} - > - <IconTrashOutline16 /> - </button> - ) - : null} - </div> - {state.revealedPaths[row.id] === undefined - ? null - : ( - <p className={css.revealedPath}> - <span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span> - <code>{state.revealedPaths[row.id]}</code> - </p> - )} - </li> - ))} - </ul> + </li> + ))} + </ul> + )} + {tail} </section> ) })} - {/* 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') - ? ( - <button - type="button" - className={css.creatorButton} - disabled={!state.authorable} - title={state.authorable ? undefined : t('duplicateUnavailable')} - onClick={() => { - props.startCreatorDraft?.() - props.close() - }} - > - {/* Same glyph as the Models page's add affordances. */} - <IconPlusOutline16 size={14} /> - {t('creatorDraft')} - </button> - ) - : null} <CopyDialog state={state} t={t} diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 913639e9ab..150867a177 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -114,6 +114,7 @@ export function apply(ctx: ClientContext): void { hooks: { agentPresetSeat: seat.store }, load: () => 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<AgentPresetSettingsState> = {}) { @@ -56,7 +57,11 @@ function renderRow(state: Partial<AgentPresetSettingsState> = {}) { function renderSeat(state: Partial<AgentPresetSeatState> = {}) { const store = createSnapshotStore<AgentPresetSeatState>({ ...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(<AgentPresetSeat {...({ ...actions, useAgentPresetSeat: bindSnapshotSelector(store), diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 73f4080c1c..47bd4e274d 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 <span className={css.triggerIcon} aria-hidden>{permissionGlyph(currentValue)}</span> )} <span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span> - {/* Same glyph + open rotation as the sibling ModelSelect trigger. */} - <span className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden> - <IconChevronDownOutline14 /> - </span> + {/* 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. */} + <IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} /> </button> } /> 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) => ( </svg> ) +/** 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) => ( + <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <mask id="mask0_agent_preset_16" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"> + <rect width="16" height="16" fill="white" /> + <circle cx="7.9995" cy="3.28319" r="1.712" fill="black" /> + <circle cx="3.51122" cy="11.3855" r="1.712" fill="black" /> + <circle cx="12.4878" cy="11.3855" r="1.712" fill="black" /> + </mask> + <path + mask="url(#mask0_agent_preset_16)" + d="M12.2881 11.0425C12.6002 11.3723 13.0413 11.5786 13.5312 11.5786L13.5342 11.5776C13.1476 12.3233 12.6119 12.9785 11.9639 13.5005C10.9327 14.3309 9.6199 14.8286 8.19336 14.8286C7.29864 14.8285 6.45056 14.6313 5.6875 14.2808C6.08309 14.0281 6.36707 13.6189 6.45215 13.1392C6.99022 13.3561 7.57767 13.476 8.19336 13.4761C9.30019 13.4761 10.3157 13.0915 11.1152 12.4478C11.5935 12.0626 11.9924 11.5848 12.2881 11.0425ZM4.14746 4.36475C4.25569 4.83228 4.55488 5.2247 4.95898 5.4585C4.07956 6.30639 3.53144 7.49605 3.53125 8.81396C3.53125 9.69534 3.77613 10.5202 4.20117 11.2231C3.74959 11.3817 3.38395 11.7232 3.19531 12.1597C2.5541 11.2032 2.17969 10.052 2.17969 8.81396C2.17989 7.05087 2.93868 5.4646 4.14746 4.36475ZM8.19336 2.80029C8.85717 2.80029 9.49784 2.90834 10.0967 3.10791C12.3237 3.85044 13.9725 5.86061 14.1846 8.28369C13.9832 8.20048 13.7627 8.15382 13.5312 8.15381C13.2802 8.15381 13.042 8.20907 12.8271 8.30615C12.6281 6.47264 11.3666 4.95616 9.66895 4.39014C9.2063 4.236 8.70989 4.15186 8.19336 4.15186C7.96112 4.15189 7.7329 4.16981 7.50977 4.20264C7.51947 4.12886 7.52637 4.05348 7.52637 3.97705C7.52628 3.56604 7.3811 3.18914 7.13965 2.89404C7.48183 2.83352 7.83381 2.80033 8.19336 2.80029Z" + fill="currentColor" + /> + <path + d="M9.1123 3.28271C9.11205 2.66858 8.61322 2.17041 7.99902 2.17041C7.38504 2.17067 6.88697 2.66874 6.88672 3.28271C6.88672 3.89691 7.38489 4.39574 7.99902 4.396C8.61338 4.396 9.1123 3.89707 9.1123 3.28271ZM10.3115 3.28271C10.3115 4.55981 9.27612 5.59521 7.99902 5.59521C6.72214 5.59496 5.6875 4.55965 5.6875 3.28271C5.68776 2.00599 6.7223 0.971447 7.99902 0.971191C9.27596 0.971191 10.3113 2.00584 10.3115 3.28271Z" + fill="currentColor" + /> + <path + d="M4.62402 11.385C4.62377 10.7709 4.12494 10.2727 3.51074 10.2727C2.89676 10.273 2.39869 10.771 2.39844 11.385C2.39844 11.9992 2.89661 12.498 3.51074 12.4983C4.1251 12.4983 4.62402 11.9994 4.62402 11.385ZM5.82324 11.385C5.82324 12.6621 4.78784 13.6975 3.51074 13.6975C2.23386 13.6973 1.19922 12.6619 1.19922 11.385C1.19947 10.1083 2.23402 9.07374 3.51074 9.07349C4.78768 9.07349 5.82299 10.1081 5.82324 11.385Z" + fill="currentColor" + /> + <path + d="M13.6006 11.385C13.6003 10.7709 13.1015 10.2727 12.4873 10.2727C11.8733 10.273 11.3753 10.771 11.375 11.385C11.375 11.9992 11.8732 12.498 12.4873 12.4983C13.1017 12.4983 13.6006 11.9994 13.6006 11.385ZM14.7998 11.385C14.7998 12.6621 13.7644 13.6975 12.4873 13.6975C11.2104 13.6973 10.1758 12.6619 10.1758 11.385C10.176 10.1083 11.2106 9.07374 12.4873 9.07349C13.7642 9.07349 14.7995 10.1081 14.7998 11.385Z" + fill="currentColor" + /> + </svg> +) + /** ic_ds_browse_outline_16 */ export const IconBrowseOutline16 = ({ size = 16, className }: IconProps) => ( <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 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 <IconDataOutline16 className={css.navIcon} size={16} /> - if (id === 'agent-presets') return <IconThinkOutline16 className={css.navIcon} size={16} /> + if (id === 'agent-presets') return <IconAgentPresetOutline16 className={css.navIcon} size={16} /> return <IconSettingsOutline16 className={css.navIcon} size={16} /> } 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(<SubagentCatalogAction {...loading} />) - 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(<SubagentCatalogAction {...failed} />) - 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 993550e6c851e1ae05aa7d587d10ae1626ae1191 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Sun, 9 Aug 2026 13:13:30 +0800 Subject: [PATCH 127/229] refactor: remove repository plugin path --- ...-manager-native-repository-cache.i18n.yaml | 6 - ...package-manager-native-repository-cache.md | 47 -- ...kage-manager-native-repository-cache.zh.md | 47 -- ...-static-repository-plugin-format.i18n.yaml | 6 - ...6-07-30-static-repository-plugin-format.md | 49 -- ...7-30-static-repository-plugin-format.zh.md | 49 -- ...-trusted-repository-package-code.i18n.yaml | 6 - ...6-08-08-trusted-repository-package-code.md | 51 -- ...8-08-trusted-repository-package-code.zh.md | 51 -- ...it-repository-plugin-preparation.i18n.yaml | 6 - ...acked-git-repository-plugin-preparation.md | 48 -- ...ed-git-repository-plugin-preparation.zh.md | 48 -- ...-self-referential-cordis-toolset.i18n.yaml | 4 +- ...6-07-08-self-referential-cordis-toolset.md | 2 +- ...7-08-self-referential-cordis-toolset.zh.md | 2 +- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 4 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 4 +- ...26-07-30-config-only-repository-plugins.md | 50 -- ...07-30-config-only-repository-plugins.zh.md | 50 -- ...-08-09-remove-repository-plugin.i18n.yaml} | 6 +- .../2026-08-09-remove-repository-plugin.md | 43 ++ .../2026-08-09-remove-repository-plugin.zh.md | 43 ++ ...07-17-sdk-follow-up-capabilities.i18n.yaml | 4 +- .../2026-07-17-sdk-follow-up-capabilities.md | 2 +- ...026-07-17-sdk-follow-up-capabilities.zh.md | 2 +- .github/workflows/ci.yml | 13 - THIRD_PARTY_NOTICES.md | 1 - apps/cli/composition.md | 3 - apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 4 +- apps/cli/reference/README.zh.md | 4 +- .../.dsh-plugin/.mcp.json | 10 - .../.dsh-plugin/package.json | 30 - .../.dsh-plugin/src/mcp-server.ts | 19 - .../.dsh-plugin/src/plugin.ts | 59 -- .../.dsh-plugin/tsconfig.json | 13 - .../skills/github-source-proof/SKILL.md | 6 - .../github-repository-plugin.built.e2e.ts | 256 ------- docs/config-catalog.md | 18 +- docs/module-graph.md | 6 - docs/tool-catalog.md | 2 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 2 +- .../system-prompt.expected.md | 2 +- .../tool-schemas.expected.json | 2 +- .../tests/fixtures/cli.cordis.yml | 3 - .../skills/0/repository-fixture/SKILL.md | 6 - .../fixtures/repository-plugin/dsh-plugin.mjs | 19 - .../tests/fixtures/repository-plugin/load.mjs | 13 - .../headless-agent/tests/keyless-smoke.e2e.ts | 45 +- .../advanced-toolchain/session.1.jsonl | 8 +- .../advanced-toolchain/session.2.jsonl | 8 +- .../advanced-toolchain/session.jsonl | 28 +- examples/package.json | 1 - knip.json | 14 - packages/README.i18n.yaml | 4 +- packages/README.md | 2 +- packages/README.zh.md | 2 +- .../app-boot/tests/repository-cache.spec.ts | 218 ------ packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 7 - packages/bundle/base/package.json | 1 - packages/mcp/mcp-client/src/index.ts | 4 +- packages/self-modification/README.i18n.yaml | 4 +- packages/self-modification/README.md | 3 +- packages/self-modification/README.zh.md | 3 +- .../repository-plugin/README.i18n.yaml | 6 - .../repository-plugin/README.md | 128 ---- .../repository-plugin/README.zh.md | 128 ---- .../repository-plugin/package.json | 73 -- .../repository-plugin/src/bin.ts | 12 - .../repository-plugin/src/format.ts | 249 ------- .../repository-plugin/src/index.ts | 147 ---- .../repository-plugin/src/invariant.ts | 30 - .../repository-plugin/src/mcp.ts | 156 ---- .../repository-plugin/src/source.ts | 130 ---- .../tests/mcp-format.spec.ts | 121 ---- .../tests/repository-plugin.spec.ts | 676 ------------------ .../repository-plugin/tsconfig.json | 33 - .../repository-plugin/tsdown.config.ts | 17 - .../tool-cordis/README.i18n.yaml | 4 +- .../self-modification/tool-cordis/README.md | 2 +- .../tool-cordis/README.zh.md | 2 +- .../tool-cordis/src/index.ts | 2 +- packages/skill/skill-local/README.i18n.yaml | 4 +- packages/skill/skill-local/README.md | 2 +- packages/skill/skill-local/README.zh.md | 2 +- packages/skill/skill-local/src/index.ts | 5 +- pnpm-lock.yaml | 50 -- scripts/run-gates.spec.ts | 14 +- scripts/run-gates.ts | 17 +- tsconfig.host.json | 1 - vendor/README.md | 11 +- vendor/loader/package.json | 8 +- vendor/loader/src/repository.ts | 258 ------- vendor/loader/tsdown.config.ts | 2 - 100 files changed, 171 insertions(+), 3612 deletions(-) delete mode 100644 .agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml delete mode 100644 .agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md delete mode 100644 .agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md delete mode 100644 .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml delete mode 100644 .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md delete mode 100644 .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md delete mode 100644 .agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml delete mode 100644 .agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md delete mode 100644 .agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.i18n.yaml delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md delete mode 100644 .agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md rename .agents/notes/implemented/{feature/2026-07-30-config-only-repository-plugins.i18n.yaml => simplification/2026-08-09-remove-repository-plugin.i18n.yaml} (56%) create mode 100644 .agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.md create mode 100644 .agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.zh.md delete mode 100644 apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json delete mode 100644 apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json delete mode 100644 apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts delete mode 100644 apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts delete mode 100644 apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json delete mode 100644 apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md delete mode 100644 apps/cli/tests/github-repository-plugin.built.e2e.ts delete mode 100644 examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md delete mode 100644 examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs delete mode 100644 examples/headless-agent/tests/fixtures/repository-plugin/load.mjs delete mode 100644 packages/boot/app-boot/tests/repository-cache.spec.ts delete mode 100644 packages/self-modification/repository-plugin/README.i18n.yaml delete mode 100644 packages/self-modification/repository-plugin/README.md delete mode 100644 packages/self-modification/repository-plugin/README.zh.md delete mode 100644 packages/self-modification/repository-plugin/package.json delete mode 100644 packages/self-modification/repository-plugin/src/bin.ts delete mode 100644 packages/self-modification/repository-plugin/src/format.ts delete mode 100644 packages/self-modification/repository-plugin/src/index.ts delete mode 100644 packages/self-modification/repository-plugin/src/invariant.ts delete mode 100644 packages/self-modification/repository-plugin/src/mcp.ts delete mode 100644 packages/self-modification/repository-plugin/src/source.ts delete mode 100644 packages/self-modification/repository-plugin/tests/mcp-format.spec.ts delete mode 100644 packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts delete mode 100644 packages/self-modification/repository-plugin/tsconfig.json delete mode 100644 packages/self-modification/repository-plugin/tsdown.config.ts delete mode 100644 vendor/loader/src/repository.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml deleted file mode 100644 index df68353802..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md -2026-07-30-package-manager-native-repository-cache.md: 38e7356d4abfc8eba8854f0a96700da448ff4ac4 -2026-07-30-package-manager-native-repository-cache.zh.md: 6833eeb4279c9feb9ac1860e780b8ed58b09d334 diff --git a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md deleted file mode 100644 index 38e7356d4a..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md +++ /dev/null @@ -1,47 +0,0 @@ -# Agent Note: Package-manager-native repository cache - -Status: implemented - -English | [中文](2026-07-30-package-manager-native-repository-cache.zh.md) - -## Problem - -A standalone Harness app cannot rely on a developer-owned SDK project to declare and install repository dependencies. Loading a configured GitHub repository therefore needs a persistent fetch, preparation, and cache boundary, but implementing Git transport, hosted-source syntax, package preparation, and a content store inside DSH would duplicate a package manager. Requiring a separately installed package manager would make a config-only feature depend on host setup. - -The cache also needs an update identity. A mutable branch name cannot both remain permanently cached and reflect later commits without an independent refresh protocol. - -## Decision - -Vendored `@cordisjs/plugin-loader/repository` exports `RepositoryCache`, a generic Node-only package helper with no DSH plugin-format knowledge. Keeping it on a subpath prevents browser consumers of the Loader's main entry from traversing Node filesystem and child-process imports. The caller supplies a package-manager-native source specifier and a cache root. DSH-specific callers own accepted source syntax, path selection, and the cache-root location; the [SDK project dependency workflow](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md#external-cordis-plugin-installation) remains a separate path owned by the developer project's selected package manager. - -The Loader carries an exact runtime dependency on `pnpm@11.7.0` and invokes that package's JavaScript entry with the current Node executable. It never discovers a global executable or delegates through Corepack. Each cache miss creates an isolated project with one dependency named `repository`; pnpm owns Git/GitHub resolution, fetching, its content-addressed store, dependency installation, and lifecycle scripts in the repository's dependency graph. - -The isolated workspace sets `dangerouslyAllowAllBuilds: true`. A configured repository and its dependency graph are trusted executable code: lifecycle scripts may run before DSH reads any declared assets. The child receives ordinary host process state needed by Git and pnpm, but ambient credential-shaped (`KEY`, `PASSWORD`, `SECRET`, `TOKEN`) variables are removed. No OAuth, token forwarding, or private-repository authentication contract is added. - -The SHA-256 of the exact specifier names the cache entry. Concurrent same-process requests share one task. Installation occurs in a sibling temporary directory; only a successful install with a package directory and marker is atomically renamed into the final key. Failed staging is removed, and a competing process's already-published valid entry wins. A later process validates the marker and package directory before returning the stable `node_modules/repository` path. - -An identical specifier permanently reuses its published entry. The caller changes the ref or another part of the specifier to request a new generation; the cache does not poll remotes, reinterpret mutable refs, expire entries, or garbage-collect old generations. - -## Alternatives considered - -**Implement GitHub download, archive extraction, preparation, and caching directly.** Rejected under the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md): pnpm already owns hosted Git syntax, Git execution, lifecycle policy, and a shared content store. A second resolver would add more code while still needing package semantics. - -**Require `pnpm` on `PATH` or invoke Corepack.** Rejected because changing one app config must be sufficient on every supported installation. Pinning and shipping the CLI also makes the preparation policy reviewable and independent of the host's package-manager version. - -**Resolve a branch or tag again on every startup.** Rejected because it turns startup into a network refresh, changes code without a config diff, and makes rollback depend on remote state. Explicit ref changes preserve auditability even when a user deliberately chooses a mutable ref. - -**Disable repository lifecycle scripts.** Rejected because common plugin repositories need a declarative `prepare` step to validate and package their plugin subdirectory. The trust boundary is explicit configuration of executable source, not an incomplete illusion that only static files can run. - -**Introduce a Cordis repository service.** Rejected because cache lookup has no runtime contribution registry or provider variation. A small helper lets the later host own Cordis lifecycle and HMR without adding a service contract prematurely. - -## Consequences - -- Standalone apps carry pnpm's approximately 18.6 MB unpacked runtime instead of requiring a global tool or owning a Git/package implementation. -- A repository author may use ordinary package preparation, and a malicious configured repository or dependency can execute code with the scrubbed child environment and the user's filesystem authority. -- Exact specifiers make startup deterministic after the first successful install; changing cached code requires a config/ref change. -- Failed installs leave no published cache entry and may be retried. Published corruption fails loud instead of silently reinstalling under the same identity. -- Cache generations consume disk until a future explicit cache-management policy removes them. - -## Testing - -`packages/boot/app-boot/tests/repository-cache.spec.ts` covers same-process single-flight, cross-instance cache reuse, exact-specifier separation, failed-stage cleanup and retry, and boundary validation. Its real local-Git case invokes the bundled pnpm, runs the fixture repository's `prepare` script, and reads the prepared file from the installed cache entry without network access. diff --git a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md b/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md deleted file mode 100644 index 6833eeb427..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md +++ /dev/null @@ -1,47 +0,0 @@ -# Agent Note: 包管理器原生仓库缓存 - -Status: implemented - -[English](2026-07-30-package-manager-native-repository-cache.md) | 中文 - -## 问题 - -独立运行的 Harness 应用不能依赖开发者自有的 SDK 工程来声明并安装仓库依赖。因此,加载配置中的 GitHub 仓库需要一道持久的获取、准备与缓存边界;但如果在 DSH 内实现 Git 传输、托管来源语法、包准备流程和内容存储,就会重复实现包管理器。若要求用户另行安装包管理器,则只需修改配置即可使用的功能还会依赖宿主环境的额外配置。 - -缓存还需要明确更新标识。若没有独立的刷新协议,可变分支名无法既永久缓存,又反映后续 commit。 - -## 决策 - -vendor 中的 `@cordisjs/plugin-loader/repository` 导出 `RepositoryCache`:一个不包含 DSH 插件格式知识、仅限 Node 使用的通用包辅助工具。把它保留在子路径上,可以避免 Loader 主入口的浏览器消费方在解析依赖时遍历到 Node 文件系统和子进程 import。调用方提供包管理器原生的来源 specifier 和缓存根目录。DSH 专属调用方负责规定可接受的来源语法、路径选择与缓存根目录位置;[SDK 工程依赖工作流](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md#external-cordis-plugin-installation)仍是另一条路径,由开发者工程选定的包管理器负责。 - -Loader 将 `pnpm@11.7.0` 作为固定版本的运行时依赖,并使用当前 Node 可执行文件调用该包的 JavaScript 入口。它绝不探测全局可执行文件,也不经 Corepack 调用。每次缓存未命中都会创建一个隔离工程,其中只有一个名为 `repository` 的依赖;Git 与 GitHub 来源的解析和获取、pnpm 自身的内容寻址 store、依赖安装,以及仓库依赖图中的生命周期脚本均由 pnpm 负责。 - -隔离工作区设置 `dangerouslyAllowAllBuilds: true`。用户配置的仓库及其依赖图都属于受信任的可执行代码:DSH 读取任何已声明资产之前,生命周期脚本就可能运行。子进程会收到 Git 与 pnpm 所需的常规宿主进程状态,但会移除环境中名称形似凭据(`KEY`、`PASSWORD`、`SECRET`、`TOKEN`)的变量。该机制不新增 OAuth、token 转发或私有仓库认证约定。 - -缓存项以精确 specifier 的 SHA-256 命名。同一进程内针对相同 specifier 的并发请求共享一项任务。安装在同级临时目录中进行;只有安装成功且存在包目录和标记时,系统才会把暂存目录原子重命名为最终键对应的目录。失败的暂存目录会被删除;如果另一进程已发布有效项,则以该项为准。后续进程会先校验标记与包目录,再返回稳定的 `node_modules/repository` 路径。 - -相同的 specifier 会永久复用已发布项。调用方通过修改 ref 或 specifier 的其他部分来请求新的缓存代次;缓存不会轮询远端、重新解释可变 ref、让条目过期,也不会垃圾回收旧代次。 - -## 曾考虑的替代方案 - -**直接实现 GitHub 下载、归档解压、准备与缓存。** 根据[依赖政策](../process/2026-07-26-dependencies-over-hand-rolling.md)不予采纳:pnpm 已负责托管 Git 语法、Git 执行、生命周期政策和共享内容存储。第二套解析器会增加更多代码,却仍需实现包语义。 - -**要求 `pnpm` 位于 `PATH` 上,或调用 Corepack。** 不予采纳:在每种受支持的安装形态中,只修改一份应用配置就必须足以启用该功能。固定并随应用分发 CLI(命令行界面)还能使准备政策可供评审,并与宿主的包管理器版本无关。 - -**每次启动都重新解析分支或 tag。** 不予采纳:这会把启动变成网络刷新,在配置 diff 未变化时更改代码,并让回滚依赖远端状态。即使用户有意选择可变 ref,显式修改 ref 仍能保持可审计性。 - -**禁用仓库生命周期脚本。** 不予采纳:常见插件仓库需要声明式 `prepare` 步骤来校验并打包插件子目录。信任边界是显式配置可执行来源,而不是营造一种不完整的假象,仿佛只有静态文件能够运行。 - -**引入 Cordis 仓库服务。** 不予采纳:缓存查找没有运行时贡献注册表,也不存在提供方变体。小型 helper 让后续宿主负责 Cordis 生命周期与 HMR(热模块替换),无需过早新增服务约定。 - -## 后果 - -- 独立应用随附 pnpm 约 18.6 MB 的解压后运行时,不要求全局工具,也无需自行实现 Git 与包处理。 -- 仓库作者可以使用常规包准备流程;恶意的已配置仓库或依赖可以在经过上述清理的子进程环境中,以用户的文件系统权限执行代码。 -- 精确 specifier 使首次安装成功后的启动具有确定性;更改缓存代码必须修改配置或 ref。 -- 安装失败不会留下已发布缓存项,可以再次重试。已发布缓存损坏时会明确报错,而不会在同一标识下静默重装。 -- 缓存代次会持续占用磁盘,直到未来有明确的缓存管理政策将其移除。 - -## 测试 - -`packages/boot/app-boot/tests/repository-cache.spec.ts` 覆盖同进程 single-flight、跨实例缓存复用、精确 specifier 隔离、失败暂存清理与重试,以及边界校验。其真实本地 Git 用例会调用随附的 pnpm,运行 fixture(测试前置数据)仓库的 `prepare` 脚本,并在不访问网络的情况下,从已安装缓存项中读取准备后的文件。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml deleted file mode 100644 index ff9e0f5bbc..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md -2026-07-30-static-repository-plugin-format.md: c66ee111eb0cac9e0d6c54581855ffc18efc8611 -2026-07-30-static-repository-plugin-format.zh.md: c85aaf44d96098eb1ccb45456cce1a15e7408fc5 diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md deleted file mode 100644 index c66ee111eb..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md +++ /dev/null @@ -1,49 +0,0 @@ -# Agent Note: Static repository Plugin format - -Status: implemented - -English | [中文](2026-07-30-static-repository-plugin-format.zh.md) - -## Problem - -A repository that already contains reusable skills or an MCP server declaration should be usable by standalone Harness applications without becoming a Harness SDK project or rewriting its existing layout. Popular repositories must be able to add one `.dsh-plugin` directory while keeping their current skills and `.mcp.json` elsewhere in the tree. These portable static contributions still need to reuse the existing skill and MCP lifecycle owners when the same trusted package also carries native Cordis code. - -The [package-manager-native repository cache](2026-07-30-package-manager-native-repository-cache.md) prepares an exact package source but intentionally knows nothing about DSH formats. This layer therefore needs a package-manager-compatible authoring format, a deterministic prepared artifact, and a Cordis composition that stays transactional under Loader disposal and replacement. - -## Decision - -`@deepseek-ai/dsh-repository-plugin` owns the static contribution subformat inside a `.dsh-plugin` package: skill roots and one common `.mcp.json`. Its package metadata uses `package.json#dsh.skills` for relative skill-root paths and `package.json#dsh.mcpServers` for the relative MCP document path. Each path may leave `.dsh-plugin` to reuse repository content but must remain beneath the directory containing that `.dsh-plugin`; a nested selectable Plugin therefore owns the adjacent subtree above its package without gaining access to unrelated host paths. The package may additionally declare the explicit code entry owned by the [trusted repository package decision](2026-08-08-trusted-repository-package-code.md), and at least one code or static contribution is required. - -The `.dsh-plugin` package declares the published `@deepseek-ai/dsh-repository-plugin` package as a development dependency and a non-empty `scripts.prepack` that invokes its `dsh-plugin-prepare` executable. During Git installation, pnpm installs that dependency from the selected package's own manifest; `prepack` runs after dependency installation and before pnpm packs a selected subdirectory, including a Plugin nested inside another package-manager workspace. The package may build its code first. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`; the source loader revalidates the installed package's helper-bearing lifecycle metadata before importing that wrapper. A static-only package still receives an import-free wrapper containing its normalized manifest, service-derived `inject` list, and delegation to the `dsh-repository-plugin` Loader builtin. The dependency and workspace-isolation rationale is in the [Git source preparation repair](../bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md). - -Loading the DSH package registers that builtin as an effect. A generated wrapper mounts the builtin as its child with `import.meta.url`, so all contributions belong to the wrapper fiber and disappear on Loader removal or rollback. The builtin revalidates the prepared manifest and path containment before reading assets. It composes the existing implementations rather than registering skills or MCP tools itself. - -Each prepared skill set mounts `dsh-skill-local` with a unique `repository:<package-name>` provider name, only the copied custom roots, and watching disabled. `dsh-skill-local` therefore gains two general configuration fields: `providerName` and `includeDefaultRoots`. Their defaults preserve its existing single local provider; repository instances set a distinct name and exclude project/user roots so multiple instances neither collide nor duplicate host-local discovery. - -Each `.mcp.json` server becomes one existing `dsh-mcp-client` child. The adapter accepts the common root `{ "mcpServers": ... }`; stdio definitions allow only optional `type: "stdio"`, `command`, `args`, and `env`, while HTTP definitions allow only `type: "http"`, `url`, and `headers`. Exact `${NAME}` process-environment references expand at runtime, after cache preparation; missing names fail Plugin load. HTTP maps to the client's Streamable HTTP transport, and stdio uses the prepared package directory as `cwd`. The existing client alone owns connection attempts, failure logging, remote tool synchronization, tool calls, and disconnects. Repository instances enable strict startup, so an initial connection, discovery, or tool-registration failure rejects the repository Loader generation; non-strict standalone clients retain the logged successful-plugin/no-tools behavior. - -Unknown MCP fields reject. This intentionally excludes OAuth, `auth` objects, `CLAUDE_PLUGIN_ROOT`, and a broader Claude compatibility contract. Commands, hooks, agents, rules, and other foreign manifest conventions are not inferred from static repository layout; DSH-native behavior uses the explicit trusted Cordis entry. Repository subdirectory selection and GitHub source configuration belong to the [standalone app integration](../feature/2026-07-30-config-only-repository-plugins.md), not this static adapter. - -## Alternatives considered - -**Discover an entry from `main`, `exports`, or repository layout.** Rejected because static assets do not imply that a package's ordinary entry is a Cordis Plugin. Trusted code loading is explicit through `dsh.entry` and remains outside this static adapter's ownership. - -**Teach generated wrappers to implement skills and MCP directly.** Rejected because copied runtime code would drift from `dsh-skill-local` and `dsh-mcp-client`, especially their provider invalidation, tool synchronization, failure, and teardown contracts. - -**Import Harness packages from each generated wrapper.** Rejected because repository packages should not resolve or version the application's internal dependency graph. A Loader builtin supplies one app-owned implementation and keeps generated wrappers import-free. - -**Watch prepared repository assets.** Rejected because an exact repository cache generation is immutable. Ref, subdirectory, or configuration changes select a new generation; a second watcher would create an unowned refresh identity. - -**Make every MCP connect failure a Loader update failure.** Rejected because optional standalone MCP clients deliberately contain startup failures and expose no tools. The MCP client instead owns an explicit strict-startup option, which repository adapters enable for their declared servers. - -## Consequences - -- Existing skill/MCP repositories can add a small `.dsh-plugin/package.json` without relocating their assets or adopting an SDK project. -- Prepared static output is deterministic glue, while an optional `dsh.entry` and the configured repository lifecycle remain trusted executable package-manager input rather than a sandbox. -- Multiple repository Plugins coexist through provider names and ordinary MCP server-name uniqueness; duplicate names fail through their existing registries and participate in Loader rollback. -- Cached source edits do not appear live. Another exact source/ref/path/config selection is required. -- Adding another portable static contribution kind requires an explicit format and DSH-owned runtime consumer; DSH-native behavior uses the separate explicit code entry. - -## Testing - -Focused tests prepare skills and MCP metadata, prove a static-only wrapper contains no imports, reject Work IQ-style OAuth fields, map Expo-style HTTP and DataJunction-style stdio plus environment values, and exercise missing variables. A real Loader test mounts a generated wrapper through the registered builtin, reads its skill through `ctx.skills`, removes the Loader entry, and observes provider cleanup. The CI built-entry acceptance invokes `dsh run` with a GitHub source pinned to the pull request head and observes the copied skill alongside the trusted code and MCP proofs owned by the superseding decision. diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md b/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md deleted file mode 100644 index c85aaf44d9..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md +++ /dev/null @@ -1,49 +0,0 @@ -# Agent Note: 静态 repository Plugin 格式 - -状态:已实现 - -[English](2026-07-30-static-repository-plugin-format.md) | 中文 - -## 问题 - -一个已经包含可复用 skills 或 MCP server 声明的仓库,应当能被独立 Harness 应用使用,而不必先变成 Harness SDK 项目,也不应被迫改写现有布局。常见仓库只需新增一个 `.dsh-plugin` 目录,同时仍可把原有 skills 与 `.mcp.json` 放在仓库其他位置。当同一个受信任包还携带原生 Cordis 代码时,这些可移植静态贡献仍需复用现有的 skill 与 MCP 生命周期所有者。 - -[Package-manager-native repository cache](2026-07-30-package-manager-native-repository-cache.md) 会准备一个精确 package source,但有意不了解任何 DSH 格式。因此本层需要一种兼容 package manager 的创作格式、确定性的已准备产物,以及在 Loader dispose 和替换期间仍保持事务性的 Cordis 组合。 - -## 决策 - -`@deepseek-ai/dsh-repository-plugin` 负责 `.dsh-plugin` 包内的静态贡献子格式:skill 根和一个通用 `.mcp.json`。其包元数据使用 `package.json#dsh.skills` 声明相对 skill 根路径,使用 `package.json#dsh.mcpServers` 声明相对 MCP 文档路径。每条路径都可以离开 `.dsh-plugin` 以复用仓库内容,但必须留在包含该 `.dsh-plugin` 的目录之下;因此,一个嵌套且可选择的插件可以拥有其包上方相邻的子树,却不能访问无关宿主路径。该包还可以声明由[受信任 repository 包决策](2026-08-08-trusted-repository-package-code.md)负责的显式代码入口,并且至少需要一种代码或静态贡献。 - -`.dsh-plugin` 包将已发布的 `@deepseek-ai/dsh-repository-plugin` 包声明为开发依赖,并声明非空 `scripts.prepack` 来调用其 `dsh-plugin-prepare` 可执行文件。在 Git 安装期间,pnpm 会按所选包自身的 manifest(元数据清单)安装该依赖;`prepack` 会在依赖安装后、pnpm 打包选定子目录前运行,即使插件嵌套在另一个包管理器工作区内也不例外。包可以先构建其代码。该辅助程序会校验元数据与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`;源码 loader 会在导入该包装层前重新校验已安装包的生命周期元数据是否包含辅助命令。仅含静态贡献的包仍会获得无 import 包装层,其中包含规范化 manifest、由服务派生的 `inject` 列表,以及对 `dsh-repository-plugin` Loader builtin 的委托。依赖与 workspace 隔离的设计依据见[Git 源准备修复](../bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md)。 - -加载 DSH package 会以 effect 方式注册该 builtin。生成的包装模块使用 `import.meta.url` 把 builtin 挂载为自己的子级,因此所有贡献都归属于包装 fiber,并在 Loader 移除或回滚时消失。Builtin 会在读取资源前重新校验已准备 manifest 与路径包含关系。它只组合现有实现,而不自行注册 skills 或 MCP 工具。 - -每份已准备 skill 集合都会挂载 `dsh-skill-local`,使用唯一的 `repository:<package-name>` 提供方名称、仅包含复制后的自定义根,并禁用监视。因此 `dsh-skill-local` 新增两个通用配置字段:`providerName` 和 `includeDefaultRoots`。默认值保持原有单一本地提供方行为;repository 实例设置不同名称并排除项目/用户根,使多个实例既不冲突,也不会重复宿主本地发现。 - -`.mcp.json` 中的每个 server 都变成一个现有 `dsh-mcp-client` 子级。适配层接受通用根对象 `{ "mcpServers": ... }`;stdio 定义只允许可选的 `type: "stdio"`、`command`、`args` 与 `env`,HTTP 定义只允许 `type: "http"`、`url` 与 `headers`。严格的 `${NAME}` 进程环境变量引用在运行时、cache 准备之后展开;缺失变量会使 Plugin 加载失败。HTTP 映射到 client 的 Streamable HTTP transport,stdio 使用已准备 package 目录作为 `cwd`。只有现有 client 负责连接尝试、失败日志、远端工具同步、工具调用和断开。Repository 实例会启用严格启动,因此初始连接、发现或工具注册失败会拒绝 repository Loader generation;非严格的独立 client 则保留“记录日志、Plugin 成功但不注册工具”的行为。 - -未知 MCP 字段会被拒绝。这里有意排除 OAuth、`auth` 对象、`CLAUDE_PLUGIN_ROOT` 和更广泛的 Claude 兼容约定。命令、hook、agent(智能体)、规则和其他外来 manifest 约定不会从静态 repository 布局中推断出来;DSH 原生行为使用显式的受信任 Cordis 入口。Repository 子目录选择与 GitHub 源配置属于[独立应用集成](../feature/2026-07-30-config-only-repository-plugins.md),而不是本静态适配器。 - -## 考虑过的替代方案 - -**从 `main`、`exports` 或 repository 布局中发现入口。** 拒绝,因为静态资源并不表示包的普通入口就是 Cordis 插件。受信任代码通过 `dsh.entry` 显式加载,不属于该静态适配器的职责。 - -**让生成包装模块直接实现 skills 和 MCP。** 拒绝,因为复制的运行时代码会与 `dsh-skill-local` 和 `dsh-mcp-client` 漂移,尤其是提供方失效、工具同步、失败和 teardown 约定。 - -**让每个生成包装模块 import Harness package。** 拒绝,因为 repository package 不应解析或锁定应用的内部依赖图。Loader builtin 提供一份由 app 所有的实现,并让生成包装模块保持无 import。 - -**监视已准备 repository 资源。** 拒绝,因为一个精确 repository cache generation 是不可变的。Ref、子目录或配置变化会选择新 generation;第二套 watcher 会创造一套没有所有者的刷新身份。 - -**把每次 MCP 连接失败都当作 Loader 更新失败。** 拒绝,因为可选的独立 MCP client 会有意收束启动失败,并且不暴露工具。MCP client 改为自行提供显式的严格启动选项,由 repository 适配器为其声明的 server 启用。 - -## 后果 - -- 现有 skill/MCP 仓库可以新增一个很小的 `.dsh-plugin/package.json`,无需移动资源或采用 SDK 项目。 -- 已准备的静态输出是确定性胶水;可选的 `dsh.entry` 和已配置的 repository 生命周期仍是受信任的可执行包管理器输入,而非沙箱。 -- 多个 repository Plugin 通过提供方名称和普通 MCP server-name 唯一性共存;重复名称经现有 registry 失败,并参与 Loader 回滚。 -- Cache 内的源码编辑不会实时出现;必须选择另一个精确 source/ref/path/config。 -- 新增可移植静态贡献类型必须提供显式格式和 DSH 自有运行时消费方;DSH 原生行为使用独立的显式代码入口。 - -## 测试 - -聚焦测试会准备 skill 与 MCP 元数据,证明仅含静态贡献的包装模块不含 import,拒绝 Work IQ 风格的 OAuth 字段,映射 Expo 风格 HTTP 与 DataJunction 风格 stdio 及环境变量,并覆盖缺失变量。真实 Loader 测试通过已注册 builtin 挂载生成包装模块,经 `ctx.skills` 读取其 skill,移除 Loader 条目并观察提供方清理。CI 构建入口验收会使用锁定到 PR(Pull Request)head 的 GitHub 源调用 `dsh run`,并观察已复制的 skill,以及由取代本决策的新决策所负责的受信任代码与 MCP 验证证据。 diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml deleted file mode 100644 index 9d49d8b1c2..0000000000 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md -2026-08-08-trusted-repository-package-code.md: 387479b3b36a8bc5e145641ae40802b3090ced70 -2026-08-08-trusted-repository-package-code.zh.md: ecc325c3dd0a9e823f1411c3a809c30ada486289 diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md deleted file mode 100644 index 387479b3b3..0000000000 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Note: Trusted repository packages load Cordis code - -Status: implemented - -English | [中文](2026-08-08-trusted-repository-package-code.zh.md) - -## Problem - -The standalone repository format already installs a selected Git package and runs its dependency and lifecycle code with host authority, but it exposed only copied skills and MCP metadata to DSH. Forbidding a Cordis entry did not create a security boundary: package installation remained trusted executable code while the restriction prevented the package from contributing the Plugin behavior that the Harness architecture is designed to compose. - -A repository author also needs to keep an ordinary TypeScript npm package shape. Requiring publication to npm, pre-generated JavaScript in Git, or a DSH-owned TypeScript compiler would make a Git source less capable than the same package installed through a developer-owned SDK project. The first model request must observe any MCP tools that this package starts; background-only initial discovery makes a successful installation nondeterministic at the application boundary. - -## Decision - -A configured repository package is trusted code. Its `.dsh-plugin/package.json` may declare `dsh.entry` as a relative path to a compiled ESM Cordis Plugin inside that package, alongside or instead of `dsh.skills` and `dsh.mcpServers`. At least one contribution is required. The entry may use namespace exports or a default export and retains ordinary Cordis semantics for `name`, `inject`, `Config`, registrations, startup failure, and effect-scoped teardown. - -The package owns its npm dependencies and build toolchain. It declares the published `@deepseek-ai/dsh-repository-plugin` package to obtain the `dsh-plugin-prepare` executable. `scripts.prepack` is a non-empty package-authored command that must invoke that dependency-provided helper, but it may first run `tsc`, `tsdown`, or any other build. DSH neither injects the helper, parses the shell program, nor compiles repository source. The helper validates the metadata after the preceding build, requires the configured entry to resolve to a file within `.dsh-plugin`, validates and copies declared static assets, and writes the prepared `dsh-plugin.mjs` wrapper. The installed package must retain a `prepack` declaration containing that helper command; a missing dependency, wrapper, or build output fails before a cache generation becomes usable. - -The generated wrapper first mounts the DSH-owned static runtime for skills and MCP definitions, then dynamically imports and unwraps the explicit entry and mounts it as a child. The wrapper statically declares dependencies implied by the prepared manifest; an entry module's additional `inject` is discovered only when mounted and must already be available in the host composition. Both children must reach Cordis `ACTIVE`; an unsatisfied `inject` or startup exception rejects the repository Loader transaction instead of committing an inert generation. Loader removal, failed replacement, and parent disposal unwind the entry, skill providers, MCP clients, and their effects together. - -`dsh-mcp-client` resolves its initial connection and tool synchronization promise as part of Plugin application. Its entry is an `async function`, not an ordinary function returning a Promise: Cordis identifies prototype-bearing ordinary functions as constructors and does not treat a constructor's returned Promise as startup work. A valid server's tools therefore exist before its parent repository wrapper activates and before a one-shot application starts its first model request. Its `failOnStartupError` config preserves optional standalone servers by default while letting repository adapters require their declared servers. Repository-translated MCP clients enable that mode, so initial connection, discovery, or tool-registration failure rejects the candidate generation and rollback still closes the transport. - -## Trust boundary - -Exact refs, source containment, credential-shaped environment scrubbing, prepared manifests, and immutable cache keys protect identity and composition integrity; they do not sandbox executable package input. Repository lifecycle scripts, transitive npm dependencies, the compiled entry, and spawned MCP servers can exercise the authority available to the DSH process and the Cordis services they receive. Users must therefore trust the selected repository and should pin immutable refs and grant Git only the narrow read credential needed for acquisition. - -Model-visible behavior remains governed by the owning DSH seam. A repository entry may register tools, prompt sections, policies, commands, agents, or other effects, but anything reaching a model request still needs the corresponding logged DSH representation and lifecycle cleanup. The repository format grants code loading; it does not weaken those service contracts. - -## Alternatives considered - -**Keep code forbidden while allowing arbitrary package lifecycles.** Rejected because installation already executes trusted repository code, so the restriction added no isolation and forced Plugin authors to publish or maintain a second integration path. - -**Have DSH compile repository TypeScript.** Rejected because compiler choice, module layout, generated chunks, native dependencies, and package metadata belong to the npm package. Running the package's declared build preserves the same boundary as other Git dependencies. - -**Import `main`, `exports`, or another discovered entry implicitly.** Rejected because an npm package may contain utilities or an MCP executable that is not a Cordis Plugin. The explicit `dsh.entry` field makes code activation reviewable and lets preparation validate the packed path. - -**Add a closed manifest field for every future DSH contribution.** Rejected as the universal extension mechanism. Skills and common MCP files retain useful portable static adapters, while DSH-native behavior composes through the existing Cordis Plugin and service contracts. - -## Consequences - -- A TypeScript DSH Plugin can live in a GitHub repository, install ordinary npm dependencies, compile during `prepack`, and run without publishing the Plugin package to npm. -- Static-only repository packages remain valid and retain import-free wrappers; adding `dsh.entry` opts that package into runtime code import. -- A package build, dependency install, entry import, unmet service, or Plugin startup failure prevents the candidate generation from replacing the last good configuration. -- Initial MCP synchronization can lengthen application startup by the MCP SDK's per-request timeout, and a repository-declared server that is unavailable or cannot publish its complete tool generation prevents that candidate generation from activating. -- Repository code receives host authority, so source review and immutable pinning are operational security requirements rather than optional hardening. - -## Testing - -Repository-format tests prepare and mount default-export code entries through the real Loader, observe an entry-owned service, remove the Loader row, and observe cleanup; they also retain skill/MCP preparation, containment, damaged-package, pending-service, and rollback coverage. MCP lifecycle tests require `apply` to settle only after initial tool publication, preserve opt-in contained startup failure, and prove strict connection or tool-registration rejection still closes the client. - -The Node 24 consumer acceptance uses the actual built `dsh run` command with a fresh DSH home and an authenticated private GitHub source pinned to the pull request's exact head SHA. The test packs the current repository Plugin build with the same private-field removal and workspace-dependency pinning used for publication, serves its packument and tarball from a job-local npm registry, and directs the Git package's ordinary scoped npm resolution there. That repository package obtains `dsh-plugin-prepare` from the simulated published dependency, installs its other pinned runtime and development dependencies, type-checks and bundles TypeScript during `prepack`, prepares a skill plus a stdio MCP server and `dsh.entry`, exposes the skill and MCP schema in the first real model request, executes the MCP tool, and lets the compiled Cordis entry append a second marker to the result observed in the following request. Registry and cache assertions require npm resolution to reach the simulated publication, source files to be absent from the packed installation, and both built modules, their installed dependency, copied assets, and generated wrapper to be present. diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md deleted file mode 100644 index ecc325c3dd..0000000000 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Note: 受信任 repository 包加载 Cordis 代码 - -状态:已实现 - -[English](2026-08-08-trusted-repository-package-code.md) | 中文 - -## 问题 - -独立 repository 格式已经会安装选定的 Git 包,并以宿主权限运行其依赖和生命周期代码,但它向 DSH 暴露的只有复制后的 skill(技能)和 MCP 元数据。禁止 Cordis 入口并未建立安全边界:包安装过程仍会执行受信任代码,而这项限制却阻止包贡献 Harness 架构本就用于组合的插件行为。 - -仓库作者还需要保持普通 TypeScript NPM 包的结构。如果要求发布到 NPM、把预生成的 JavaScript 签入 Git,或使用 DSH 自有的 TypeScript 编译器,Git 源的能力就会弱于通过开发者自有 SDK 项目安装的同一个包。首个模型请求必须看到该包启动的所有 MCP 工具;仅在后台进行初始发现,会让一次成功安装在应用边界上具有不确定性。 - -## 决策 - -已配置的 repository 包是受信任代码。其 `.dsh-plugin/package.json` 可以连同 `dsh.skills` 和 `dsh.mcpServers` 声明 `dsh.entry`,也可以用它取代二者;`dsh.entry` 是指向该包内已编译 ESM Cordis 插件的相对路径。至少需要一种贡献。入口可以使用 namespace 导出或 default export,并沿用 Cordis 对 `name`、`inject`、`Config`、注册、启动失败和 effect 作用域清理的常规语义。 - -包自行负责其 NPM 依赖和构建工具链。它声明已发布的 `@deepseek-ai/dsh-repository-plugin` 包以取得 `dsh-plugin-prepare` 可执行文件。`scripts.prepack` 是由包作者编写的非空命令,必须调用该依赖提供的辅助程序,但可以先运行 `tsc`、`tsdown` 或其他任意构建。DSH 不会注入辅助程序,也不会解析该 shell 程序或编译 repository 源码。辅助程序会在前序构建之后校验元数据,要求已配置入口解析到 `.dsh-plugin` 内的文件,校验并复制已声明的静态资源,再写入已准备的 `dsh-plugin.mjs` 包装层。已安装包必须保留包含该辅助命令的 `prepack` 声明;依赖、包装层或构建输出缺失会在缓存 generation 可用前导致失败。 - -生成的包装层先挂载 DSH 自有的静态运行时来处理 skill 和 MCP 定义,再动态导入显式入口、解包其导出并将其挂载为子级。包装层会静态声明已准备 manifest(元数据清单)所隐含的依赖;入口模块的额外 `inject` 只有在挂载时才会被发现,并且此时必须已存在于宿主组合中。两个子级都必须进入 Cordis `ACTIVE`;无法满足的 `inject` 或启动异常会拒绝 repository Loader 事务,而不会提交未激活的 generation。Loader 移除、替换失败和父级 dispose(资源释放)会一并撤销入口、skill 提供方、MCP client 及其 effect。 - -`dsh-mcp-client` 会在插件应用期间完成其初始连接和工具同步 promise。其入口必须是 `async function`,而不是返回 Promise 的普通函数:Cordis 会把带 prototype 的普通函数识别为 constructor,不会把 constructor 返回的 Promise 当作启动工作。因此,有效 server 的工具会在父级 repository 包装层激活前、一次性应用发起首个模型请求前就已存在。其 `failOnStartupError` 配置默认保留独立可选 server 的行为,同时允许 repository adapter 要求已声明 server 必须可用。Repository 转换出的 MCP client 会启用该模式,因此初始连接、发现或工具注册失败会拒绝候选 generation,回滚仍会关闭 transport。 - -## 信任边界 - -精确 ref、源路径包含约束、清除名称符合凭据模式的环境变量、已准备的 manifest 和不可变缓存键,可以保护身份与组合完整性;它们不会为可执行包输入提供沙箱隔离。Repository 生命周期脚本、传递性 NPM 依赖、已编译入口和 spawn 的 MCP server 可以行使 DSH 进程可用的权限,以及它们所获 Cordis 服务授予的权限。因此,用户必须信任所选仓库,应当固定不可变 ref,并只授予 Git 获取源码所需的最小只读凭据。 - -模型可见行为仍由所属 DSH seam 管理。repository 入口可以注册工具、提示词段落、策略、命令、agent(智能体)或其他 effect,但任何进入模型请求的内容仍须具有对应的 DSH 日志表示和生命周期清理。repository 格式授予代码加载能力;它不会削弱这些服务约定。 - -## 考虑过的替代方案 - -**继续禁止代码,但允许任意包生命周期。** 拒绝,因为安装过程本就执行受信任的 repository 代码,所以该限制没有提供隔离,反而迫使插件作者发布或维护第二条集成路径。 - -**由 DSH 编译 repository TypeScript。** 拒绝,因为编译器选择、模块布局、生成分片、原生依赖和包元数据属于 NPM 包。运行包所声明的构建,可以保持与其他 Git 依赖相同的边界。 - -**隐式导入 `main`、`exports` 或其他发现的入口。** 拒绝,因为 NPM 包可能包含并非 Cordis 插件的实用工具或 MCP 可执行文件。显式 `dsh.entry` 字段使代码激活可供评审,并让准备阶段校验打包后的路径。 - -**为未来每种 DSH 贡献添加封闭 manifest 字段。** 不采用它作为通用扩展机制。skill 和通用 MCP 文件仍保留有用的可移植静态适配器;DSH 原生行为则通过现有 Cordis 插件与服务约定组合。 - -## 后果 - -- TypeScript DSH 插件可以存放在 GitHub 仓库中,安装普通 NPM 依赖,在 `prepack` 期间完成编译,并在无需把插件包发布到 NPM 的情况下运行。 -- 仅含静态贡献的 repository 包仍然有效,并保留无 import 包装层;添加 `dsh.entry` 会使该包选择启用运行时代码导入。 -- 包构建、依赖安装、入口导入、所需服务未满足或插件启动失败,都会阻止候选 generation 替换最后一个可用配置。 -- 初始 MCP 同步可能因 MCP SDK 的单次请求超时而延长应用启动时间;repository 声明的 server 不可用或无法发布完整工具 generation 时,该候选 generation 无法激活。 -- Repository 代码获得宿主权限,因此源码评审和锁定不可变 ref 是运行安全要求,而不是可选加固措施。 - -## 测试 - -repository 格式测试通过真实 Loader 准备并挂载使用 default export 的代码入口,观察入口自有服务,移除 Loader 配置项,再观察清理;测试还保留针对 skill/MCP 准备、路径包含约束、包损坏、等待服务和回滚的覆盖。MCP 生命周期测试要求 `apply` 只在初始工具发布后完成,保留可选择启用的启动失败收束行为,并证明严格连接拒绝或工具注册拒绝仍会关闭 client。 - -Node 24 消费方验收使用实际构建的 `dsh run` 命令、全新 DSH 主目录,以及锁定到 PR(Pull Request)的精确 head SHA 且经过认证的私有 GitHub 源。测试会采用发布时相同的移除 `private` 字段和固定 workspace 依赖版本流程,对当前 repository 插件构建进行打包;再由作业本地 NPM 注册表提供其 `packument` 与 tarball,并把 Git 包的常规 scoped NPM 解析指向该注册表。该 repository 包从模拟发布的依赖取得 `dsh-plugin-prepare`,安装其他固定版本的运行时依赖与开发依赖,在 `prepack` 期间对 TypeScript 进行类型检查和打包,准备一个 skill、一个 stdio MCP server 及 `dsh.entry`,在首个真实模型请求中暴露 skill 与 MCP schema,执行 MCP 工具,并让已编译 Cordis 入口向结果追加第二个标记,供后续请求观察。注册表与缓存断言要求 NPM 解析必须命中模拟发布,打包安装中不存在源码文件,同时必须存在两个已构建模块、其已安装依赖、复制资源和生成包装层。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.i18n.yaml deleted file mode 100644 index 86e63238fa..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md -2026-08-08-npm-backed-git-repository-plugin-preparation.md: 958b932f82f4da3cf63aa911260411855e514409 -2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md: d2256e0eae303c371371b9b5ba1967105aa61834 diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md deleted file mode 100644 index 958b932f82..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md +++ /dev/null @@ -1,48 +0,0 @@ -# Agent Note: npm-backed preparation makes GitHub repository Plugins self-contained - -Status: implemented - -English | [中文](2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md) - -## Problem - -The repository Plugin authoring contract requires `scripts.prepack` to invoke `dsh-plugin-prepare`. Supplying that executable from the running DSH installation made a source package appear valid even when its own manifest could not obtain the helper. It therefore did not prove the behavior users need after `@deepseek-ai/dsh-repository-plugin` is published: an ordinary Git-hosted npm package must be installable and preparable from only its declared dependencies. - -A selectable `.dsh-plugin` inside a pnpm workspace has a second isolation requirement. pnpm prepares a Git-hosted package by running the repository's preferred package manager before packing the selected subdirectory. A nested `pnpm install` can join the containing workspace; when the root lockfile does not list `.dsh-plugin` as an importer, pnpm can report success without installing dependencies declared only by that package. Its TypeScript build or prepare command then fails, or a pre-generated artifact hides the missing dependency. - -The checked-in headless fixture mounts an already prepared wrapper. It proves runtime composition, not GitHub acquisition, npm resolution, or package-owned preparation. - -## Decision - -The `.dsh-plugin` package declares `@deepseek-ai/dsh-repository-plugin` as an ordinary development dependency and invokes its published `dsh-plugin-prepare` executable from `scripts.prepack`. The package may declare any other build and runtime dependencies and run arbitrary compilation before the helper. The repository Plugin package marks its Cordis and DSH peers optional so a helper-only development install resolves only the helper's actual `zod` runtime dependency; an application composition still supplies the peers used by the package's Cordis entry. - -DSH does not materialize or prepend a prepare executable. `RepositoryCache` supplies only a transaction-owned `pnpm` wrapper: the outer install runs the pinned pnpm entry directly, while pnpm's hard-coded Git-package `pnpm install` reinvokes the same entry with `--ignore-workspace`. The selected package therefore owns dependency resolution even beneath another pnpm lockfile, and normal package-manager lifecycle `PATH` construction exposes `node_modules/.bin/dsh-plugin-prepare`. The temporary pnpm wrapper disappears after the child settles. The repository remains trusted package-manager input: all dependency and lifecycle code executes under the existing trust contract. - -The Node 24 consumer lane passes an exact source derived from the pull request head repository and SHA. It uses the existing private DeepSeek Harness repository rather than creating another repository per run. A job-scoped Git configuration gives the read-only job token access to that exact private source and rewrites pnpm's SSH fallback to authenticated HTTPS. - -The built-entry acceptance also creates an in-process npm registry. It stages the current built `@deepseek-ai/dsh-repository-plugin` as a publication artifact by removing `private`, replacing workspace protocols with the release version, and packing the declared files. The registry serves the resulting packument and tarball, while a job-local npm config directs only the `@deepseek-ai` scope to it. The real built `dsh run` child then fetches the exact Git source; that package resolves the helper through npm, type-checks and bundles a TypeScript Cordis entry and MCP server, prepares the adjacent skill, and loads all three contributions. A deliberately failing host `PATH` command proves the lifecycle selected the dependency-local executable. The acceptance also requires registry resolution and inspects the immutable prepared cache, so restoring a host-injected helper cannot satisfy it. - -## Alternatives considered - -**Inject `dsh-plugin-prepare` from the running DSH installation.** Rejected because it lets an incomplete repository manifest pass and tests a host-only path that npm consumers cannot reproduce. - -**Publish the source fixture itself to npm.** Rejected because the product contract is specifically that the DSH Plugin remains Git-hosted; only the reusable preparation helper is an npm dependency. - -**Create a new private GitHub repository in every CI run.** Rejected because the pull request repository at its exact head SHA is already a real authenticated private Git remote. Per-run repository mutation would add credentials, cleanup, and eventual-consistency failure modes without changing the acquisition path. - -**Prepare after `RepositoryCache` installs the selected package.** Rejected because pnpm's packed subdirectory no longer contains sibling source assets referenced by paths such as `../skills`; preparation must happen before packlist. - -**Clone GitHub repositories in DSH and bypass pnpm's Git fetcher.** Rejected because it would duplicate ref resolution, subdirectory selection, dependency installation, packlist behavior, and cache integrity already owned by the pinned package manager. - -## Consequences - -- A repository author can commit a `.dsh-plugin` package, TypeScript source, skills, and MCP definitions to GitHub without publishing that Plugin package to npm. The package must declare the published preparation dependency. -- Private GitHub sources use the host's standard Git authentication. CI proves that path with a temporary read-only configuration rather than persistent runner credentials. -- `prepack`, not `prepare`, is part of the authoring format. It may contain arbitrary package-owned build steps but must invoke the dependency-provided helper; missing dependency or lifecycle metadata fails before a cache generation is usable. -- A selected package in a pnpm repository installs from its own manifest rather than an enclosing workspace. It cannot rely on workspace-only hoisting; ordinary registry and relative `file:` dependencies remain package-owned inputs. -- Exact source strings identify immutable cache generations; a changed ref or source configuration selects another generation. -- Package dependencies, compilation, preparation, and the trusted `dsh.entry` contribution remain owned by the repository package and the [trusted-code decision](../architecture/2026-08-08-trusted-repository-package-code.md). - -## Testing - -`packages/boot/app-boot/tests/repository-cache.spec.ts` runs a package excluded from its source repository's root pnpm lockfile through a local Git subpath and requires relative `file:` dependencies to provide both its build command and `dsh-plugin-prepare`; it also proves that visible environment survives while credential-shaped variables are scrubbed. `packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts` pins helper-bearing `prepack` metadata and preparation output. `examples/headless-agent/tests/keyless-smoke.e2e.ts` keeps the checked-in prepared fixture on that source contract. `apps/cli/tests/github-repository-plugin.built.e2e.ts` is the product acceptance: simulated published helper package, job-local npm registry, fresh DSH home, exact authenticated private GitHub source, actual built `dsh run`, package-owned TypeScript build, real MCP execution, code-entry transformation, mock LLM request observation, and prepared cache inspection. diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md deleted file mode 100644 index d2256e0eae..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md +++ /dev/null @@ -1,48 +0,0 @@ -# Agent Note: 基于 NPM 的准备机制使 GitHub repository 插件自包含 - -状态:已实现 - -[English](2026-08-08-npm-backed-git-repository-plugin-preparation.md) | 中文 - -## 问题 - -repository 插件创作约定要求 `scripts.prepack` 调用 `dsh-plugin-prepare`。如果由正在运行的 DSH 安装提供该可执行文件,即使源包自身的 manifest(元数据清单)无法取得辅助程序,它也会显得有效。因此,这并未证明 `@deepseek-ai/dsh-repository-plugin` 发布后用户所需的行为:普通 Git 托管 NPM 包必须只依靠自身声明的依赖即可安装和准备。 - -pnpm workspace 内可选择的 `.dsh-plugin` 还有另一项隔离要求。pnpm 会在打包所选子目录前运行仓库首选的包管理器,以准备 Git 托管包。嵌套的 `pnpm install` 可能加入外层 workspace;当根 lockfile 未把 `.dsh-plugin` 列为 importer 时,pnpm 可能报告成功,却未安装仅由该包声明的依赖。随后,其 TypeScript 构建或准备命令会失败;也可能因为存在预生成产物,依赖缺失被掩盖。 - -签入仓库的 headless fixture(测试前置数据)挂载的是已准备好的包装层。它证明运行时组合,而不证明 GitHub 获取、NPM 解析或包自有准备。 - -## 决策 - -`.dsh-plugin` 包将已发布的 `@deepseek-ai/dsh-repository-plugin` 声明为普通开发依赖,并在 `scripts.prepack` 中调用其已发布的 `dsh-plugin-prepare` 可执行文件。该包可以声明其他任意构建依赖与运行时依赖,并在辅助程序前执行任意编译。repository 插件包把 Cordis 与 DSH 对等依赖(peer dependency)标为可选,因此仅为使用辅助程序而进行的开发安装只会解析辅助程序实际依赖的 `zod` 运行时依赖;应用组合仍会提供该包 Cordis 入口所使用的对等依赖。 - -DSH 不会生成准备阶段可执行文件,也不会将其前置到 `PATH`。`RepositoryCache` 只提供一个由事务持有的 `pnpm` 包装脚本:外层安装直接运行锁定的 pnpm 入口,而 pnpm 为 Git 包硬编码的 `pnpm install` 会以 `--ignore-workspace` 重新调用同一入口。因此,即使位于另一个 pnpm lockfile 之下,所选包仍自行负责依赖解析,正常的包管理器生命周期 `PATH` 构造会暴露 `node_modules/.bin/dsh-plugin-prepare`。临时 pnpm 包装脚本会在子进程结算后消失。repository 仍是受信任的包管理器输入:所有依赖与生命周期代码都按既有信任约定执行。 - -Node 24 消费方 CI 任务会传入从 PR(Pull Request)head 仓库与 SHA 派生的精确源。它复用现有私有 DeepSeek Harness 仓库,而不会为每次运行新建仓库。作业作用域的 Git 配置允许只读作业 token 访问该精确私有源,并把 pnpm 的 SSH 回退改写为已认证 HTTPS。 - -构建入口验收还会创建一个进程内 NPM 注册表。它通过移除 `private`、将 workspace protocol 替换为发布版本并打包声明的文件,把当前已构建的 `@deepseek-ai/dsh-repository-plugin` 暂存为发布产物。注册表会提供由此生成的 `packument` 与 tarball,作业本地 NPM 配置则只把 `@deepseek-ai` scope 指向它。实际构建的 `dsh run` 子进程随后获取精确 Git 源;该包通过 NPM 解析辅助程序,对 TypeScript Cordis 入口和 MCP server 进行类型检查与打包,准备相邻的 skill(技能),并加载全部三类贡献。一个刻意设为失败的宿主 `PATH` 命令可以证明,该生命周期选中的是依赖内的可执行文件。验收还要求经过注册表解析并检查不可变的已准备缓存,因此恢复宿主注入的辅助程序也无法通过。 - -## 考虑过的替代方案 - -**从正在运行的 DSH 安装注入 `dsh-plugin-prepare`。** 拒绝,因为这会让 manifest 不完整的 repository 包通过,并测试 NPM 消费方无法复现的纯宿主路径。 - -**把源 fixture 本身发布到 NPM。** 拒绝,因为产品约定明确要求 DSH 插件仍托管在 Git;只有可复用的准备辅助程序是 NPM 依赖。 - -**在每次 CI 运行中创建新的私有 GitHub 仓库。** 拒绝,因为 PR 仓库的精确 head SHA 已是经过认证的真实私有 Git remote。每次运行的仓库变更会增加凭据、清理和最终一致性失败模式,却不改变获取路径。 - -**在 `RepositoryCache` 安装所选包后再准备。** 拒绝,因为 pnpm 打包后的子目录不再包含 `../skills` 等路径所引用的同仓库相邻资源;准备必须在生成 packlist 前完成。 - -**在 DSH 中克隆 GitHub 仓库并绕过 pnpm 的 Git 获取器。** 拒绝,因为这会重复实现已由锁定包管理器负责的 ref 解析、子目录选择、依赖安装、packlist 行为和缓存完整性。 - -## 后果 - -- 仓库作者可以把 `.dsh-plugin` 包、TypeScript 源码、skill 与 MCP 定义提交到 GitHub,而无需把该插件包发布到 NPM。该包必须声明已发布的准备依赖。 -- 私有 GitHub 源使用宿主的标准 Git 认证。CI 使用临时的只读配置而非运行器上的持久凭据来验证该路径。 -- 创作格式使用 `prepack` 而不是 `prepare`。其中可以包含任意包自有构建步骤,但必须调用依赖提供的辅助程序;依赖或生命周期元数据缺失时,会在缓存 generation 可用前失败。 -- pnpm 仓库中的所选包按自身 manifest 安装,而不继承外层 workspace。它不能依赖仅由 workspace 提升而可见的包;普通注册表依赖和相对 `file:` 依赖仍是包自有输入。 -- 精确源字符串标识不可变缓存 generation;改变 ref 或源配置会选择另一个 generation。 -- 包依赖、编译、准备和受信任的 `dsh.entry` 贡献仍由 repository 包和[受信任代码决策](../architecture/2026-08-08-trusted-repository-package-code.md)负责。 - -## 测试 - -`packages/boot/app-boot/tests/repository-cache.spec.ts` 会通过本地 Git 子路径运行一个未列入源仓库根 pnpm lockfile 的包,并要求相对 `file:` 依赖同时提供构建命令与 `dsh-plugin-prepare`;该测试还证明可见环境变量得以保留,而名称符合凭据模式的变量会被清除。`packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts` 锁定包含辅助命令的 `prepack` 元数据与准备输出。`examples/headless-agent/tests/keyless-smoke.e2e.ts` 使签入仓库的已准备 fixture 继续符合该源格式约定。`apps/cli/tests/github-repository-plugin.built.e2e.ts` 是产品验收测试:模拟发布的辅助程序包、作业本地 NPM 注册表、全新 DSH 主目录、精确且经过认证的私有 GitHub 源、实际构建的 `dsh run`、包自有 TypeScript 构建、真实 MCP 执行、代码入口转换、mock LLM(大语言模型)请求观测,以及已准备缓存检查。 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index 3023f86bba..da61b3fa95 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md -2026-07-08-self-referential-cordis-toolset.md: 5fc2fb07fcd0b00bf72c818d3806b298312cdc31 -2026-07-08-self-referential-cordis-toolset.zh.md: 8f34c97d94cad9b79a0e823406c07cdcfb38793f +2026-07-08-self-referential-cordis-toolset.md: 0d78e0adff487edae00c2422acc1ef8941e7636a +2026-07-08-self-referential-cordis-toolset.zh.md: 665387a9fb24bf0e6fc04fdfb1ced88b932742ad diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 5fc2fb07fc..0d78e0adff 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -40,7 +40,7 @@ The boundary normalizes unambiguous JSON-Schema forms into `ParameterSchemaSpec` Every temporary Plugin is a child of one internal `cordis-dynamic` group beneath the tool plugin, so ordinary fiber disposal handles toolset reload and unload. `cordis_mount` awaits settlement; startup failure disposes the fiber before returning an error. A settled pending Plugin remains visible with its missing injections. `cordis_unmount` awaits the Plugin fiber's disposal. -Temporary Plugins exist only in process memory. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and have no automatic save, promote, or install path. Keeping an experiment means asking the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. +Temporary Plugins exist only in process memory. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and have no automatic save, promote, or install path. Keeping an experiment means asking the Agent to implement a normal project Plugin or installable profile bundle through the regular development workflow. ### Cross-mount composition via provide/inject diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index 8f34c97d94..665387a9fb 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -40,7 +40,7 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 每个临时 Plugin 都是工具插件下方内部 `cordis-dynamic` 分组的子节点,因此普通的 fiber 释放即可处理工具集重载和卸载。`cordis_mount` 会等待 settlement;启动失败时在返回错误前释放 fiber。已 settle 但处于 pending 状态的 Plugin 仍然可见,并列出其缺失的注入。`cordis_unmount` 等待 Plugin fiber 的释放完成。 -临时 Plugin 只存在于进程内存中。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不存在自动保存、转正式或安装路径。若要保留实验结果,应让 Agent 通过常规开发流程实现普通的本地、项目或仓库 Plugin。 +临时 Plugin 只存在于进程内存中。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不存在自动保存、转正式或安装路径。若要保留实验结果,应让 Agent 通过常规开发流程实现普通的项目 Plugin 或可安装的 profile 组合包。 ### 通过 provide/inject 实现跨挂载组合 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 445f6548b3..a08966f790 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: f5207c5ffbd963b9b7c4a7166fa9f17a460707a9 -2026-07-20-dsh-cli-personal-config.zh.md: 24478a4b4fd5878032bf80f5b30ab9e2008da785 +2026-07-20-dsh-cli-personal-config.md: 02883c89f27e51d6091d4d65167ebdd6a96f6f51 +2026-07-20-dsh-cli-personal-config.zh.md: 6e56e892cf682ea514b036750e44dbb06e944a80 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index f5207c5ffb..02883c89f2 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -19,7 +19,7 @@ Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh **Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI, Web, and headless surfaces consume its two optional files; the demo bins boot their committed trees verbatim: - `.env` — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient > project `.env` > personal `.env`. -- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics match the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, and an unmatched id is a silent no-op. The [repository Plugin integration](2026-07-30-config-only-repository-plugins.md) uses one shipped row to make an exact GitHub source list a config-only choice. +- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics match the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, and an unmatched id is a silent no-op. External packages are installed as [profile bundles](../simplification/2026-08-09-remove-repository-plugin.md); this personal layer configures the Loader rows those bundles contribute. - A missing file means no overlay; a present-but-unreadable, unparsable, or non-array file throws at boot (misconfiguration fails loud, never a silent skip). The PTY smoke's launcher isolates `$DSH_HOME` to a per-test directory, exactly as it already isolates `DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes. @@ -40,7 +40,7 @@ The TUI and Web register the exact personal path through Cordis HMR after boot. ## Consequences -- `dsh` from any directory (and `pnpm run demo:tui`) can apply personal providers, models, repository Plugins, and other Loader entries with no checkout edit; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. +- `dsh` from any directory (and `pnpm run demo:tui`) can apply personal providers, models, installed bundle entries, and other Loader entries with no checkout edit; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. - Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](../../../../apps/cli/README.md#profiles) (which prints the composed tree those patches produce) are the diagnostics. - Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred. - `dsh-app-boot` depends on `js-yaml` and imports the include's `!!js` YAML dialect (`entryListSchema`) directly, and, like `apps/cli`, depends on `@deepseek-ai/dsh-paths` for `resolveDshHome`. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 24478a4b4f..6e56e892cf 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -19,7 +19,7 @@ Status: implemented **个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI、Web 和无头界面使用其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: - `.env`——在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境变量 > 项目 `.env` > 个人 `.env`。 -- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 静默不执行任何操作。[仓库插件集成](2026-07-30-config-only-repository-plugins.md)通过一个已交付配置项,使精确 GitHub 源列表成为纯配置选择。 +- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 静默不执行任何操作。外部包作为 [profile 组合包](../simplification/2026-08-09-remove-repository-plugin.md)安装;这个个人层负责配置这些组合包提供的 Loader 配置项。 - 文件缺失即无 overlay;文件存在但不可读、不可解析或非数组则在启动时抛出(配置错误响亮失败,绝不静默跳过)。 PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。 @@ -40,7 +40,7 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人 ## Consequences -- 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`),无需修改 checkout,即可应用个人提供方、模型、仓库插件和其他 Loader 配置项;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 +- 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`),无需修改 checkout,即可应用个人提供方、模型、已安装组合包的配置项和其他 Loader 配置项;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 - 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](../../../../apps/cli/README.md#profiles)(打印这些补丁合成出的配置树)。 - 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 - `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。 diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md deleted file mode 100644 index 35327a30e0..0000000000 --- a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md +++ /dev/null @@ -1,50 +0,0 @@ -# Agent Note: Config-only repository Plugins for standalone dsh - -Status: implemented - -English | [中文](2026-07-30-config-only-repository-plugins.zh.md) - -## Problem - -A standalone `dsh` user has no developer-owned SDK project whose `package.json`, lockfile, and `cordis.yml` can carry an external Plugin dependency. Requiring an install command or another state file would make “use this repository” a multi-step workflow, while trusted repository code still needs an exact-source, transactional lifecycle owned by the [repository package format](../architecture/2026-08-08-trusted-repository-package-code.md). Long-running TUI and Web processes also need a failed edit to preserve their usable Plugin generation and tell observers why the candidate was rejected. - -## Decision - -The shipped TUI and Web/headless `cordis.yml` trees contain an empty `repository-plugins` entry. A user changes only `$DSH_HOME/config.yaml`, replacing that entry's config with a `repositories` list. Each item uses `github:owner/repository#<ref>` plus an optional `&path:/.../.dsh-plugin`; omission selects `/.dsh-plugin`. An explicit ref is mandatory, paths are absolute within the repository and end in `.dsh-plugin`, and duplicate normalized specifiers reject before installation. There is no marketplace, discovery index, HTTPS URL vocabulary, or implicit latest generation. - -`@deepseek-ai/dsh-repository-plugin` validates and normalizes each source, then resolves it through the generic vendored [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md). The default cache is `$DSH_HOME/cache/repository-plugins`; `cacheDir` is the explicit deployment override. Bundled pnpm selects the configured repository subpackage, installs its dependencies, runs its package-authored `prepack`, and atomically publishes the exact specifier. The selected package's direct development dependency on `@deepseek-ai/dsh-repository-plugin` supplies `dsh-plugin-prepare` through package-local `node_modules/.bin`; the lifecycle invokes it after any package-owned build. The DSH host imports the generated `dsh-plugin.mjs` wrapper and mounts it as a child fiber; that wrapper composes static skill and MCP owners plus an explicit trusted Cordis entry when declared. - -## Live update and failure - -`dsh-app-boot` mounts the root Include through one helper that retains its exact Loader `Entry`. The TUI and Web register `$DSH_HOME/config.yaml` through Cordis HMR; headless reads the same file at startup without retaining a watcher. A watcher update rebuilds the Include patch list as immutable app-owned patches followed by the newly parsed personal patches, so Web-generated port, session-root, trust, and frontend values survive every personal edit unless a later personal patch deliberately replaces that row. - -Cordis serializes and coalesces exact-path changes. Include and Loader reconcile a candidate transactionally: success commits the new source list, while fetch, preparation, wrapper import, format, or child-Plugin failure rejects the candidate and retains or restores the last good tree. HMR normalizes the caught value to `Error`, logs it, and broadcasts the parallel `hmr/config-update-failed(filename, error)` event; observer failures cannot break refresh processing. Repository MCP servers use strict startup, so an initial connection, discovery, or tool-registration failure rejects the candidate and becomes a config-update failure; non-strict standalone MCP clients retain their contained successful-Plugin/no-tools behavior. - -An identical specifier permanently reuses its cache generation. HMR watches configuration, not cached repository code; the user changes the ref, path, or source list to select another generation. - -## Trust boundary - -Configuring a repository authorizes package-manager lifecycle code, dependencies, the explicit `dsh.entry`, and spawned MCP servers from that repository to run with the user's filesystem authority. The pnpm child removes ambient environment variables whose names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, but this is credential-exposure reduction rather than a sandbox. The prepared wrapper validates composition boundaries and lifecycle state; it does not make repository code safe to run when the source is untrusted. - -## Alternatives considered - -**Require an SDK project dependency.** Rejected for the standalone app path because there is no project manifest to edit. Developer-owned SDK projects keep their native package-manager workflow as a separate capability. - -**Add a `dsh plugin install` command and installation database.** Rejected because the personal Loader overlay already owns machine-local composition. A second mutation interface and durable registry would duplicate config identity and rollback. - -**Resolve repositories directly in the DSH package.** Rejected because Git transport, GitHub subpackage selection, lifecycle execution, and content storage belong to pnpm and the generic Loader cache, not a DSH-specific adapter. - -**Watch cache contents or refresh the same ref automatically.** Rejected because one config value must identify one immutable prepared generation. Background remote resolution would change executable code without a config diff and make rollback depend on mutable remote state. - -**Broadcast an `unknown` failure payload.** Rejected at the HMR boundary. JavaScript may throw any value internally, but the public event always receives a normalized `Error`, giving observers one stable contract while retaining the original value as its cause when needed. - -## Consequences - -- A repository that adds `.dsh-plugin/package.json` can reach standalone users through one personal-config edit without changing its existing skills or `.mcp.json` layout. -- Long-running apps can add, replace, or remove configured generations without restart; rejected candidates retain the last good runtime and produce one generic Cordis event. -- First use may require Git/network access and preparation time. Later starts reuse the exact prepared cache; old generations consume disk until a separate cache-management policy exists. -- Skills and common MCP definitions retain portable static adapters, while an explicit `dsh.entry` can contribute DSH-native Cordis behavior. Format-specific compatibility shims, OAuth-bearing MCP definitions, and marketplaces remain intentionally absent. - -## Testing - -Repository-package tests pin source normalization, default and nested `.dsh-plugin` paths, cache-root resolution, duplicate rejection, prepared-wrapper loading, and disposal. App-boot tests drive exact-path add, two failure classes, recovery, removal, failure events, and generated-patch preservation through the real HMR/Include/Loader path. A keyless PTY smoke boots the shipped `dsh` composition from personal config alone and invokes a skill from a seeded immutable cache generation. diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md b/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md deleted file mode 100644 index 5755045560..0000000000 --- a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md +++ /dev/null @@ -1,50 +0,0 @@ -# Agent Note: 仅凭配置为独立 dsh 接入仓库插件 - -Status: implemented - -[English](2026-07-30-config-only-repository-plugins.md) | 中文 - -## 问题 - -独立 `dsh` 用户没有开发者自有的 SDK 项目,无法由其 `package.json`、lockfile 和 `cordis.yml` 承载外部插件依赖。若要求运行安装命令或维护另一份状态文件,「使用这个仓库」就会变成多步骤流程;受信任的 repository 代码仍需要由[repository 包格式](../architecture/2026-08-08-trusted-repository-package-code.md)负责一套锁定精确来源且具事务性的生命周期。长时间运行的 TUI 和 Web 进程还必须在编辑失败时保留仍可使用的插件版本,并向观察者说明候选配置被拒绝的原因。 - -## 决策 - -已交付的 TUI 和 Web/无头 `cordis.yml` 配置树包含一个空的 `repository-plugins` 配置项。用户只需修改 `$DSH_HOME/config.yaml`,用 `repositories` 列表替换该配置项的配置。每一项采用 `github:owner/repository#<ref>`,并可追加 `&path:/.../.dsh-plugin`;省略时选择 `/.dsh-plugin`。必须显式指定 ref;路径是仓库内的绝对路径,并以 `.dsh-plugin` 结尾;重复的规范化说明符在安装前即被拒绝。不提供插件市场、发现索引、HTTPS URL 词汇或隐式的最新版本。 - -`@deepseek-ai/dsh-repository-plugin` 校验并规范化每个源,再通过 vendor 中的通用 [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md) 解析。默认缓存位于 `$DSH_HOME/cache/repository-plugins`;`cacheDir` 是显式的部署覆盖项。随应用提供的 pnpm 选择已配置的 repository 子包,安装其依赖,运行包所定义的 `prepack`,并原子发布该精确说明符。所选包对 `@deepseek-ai/dsh-repository-plugin` 的直接开发依赖通过包内 `node_modules/.bin` 提供 `dsh-plugin-prepare`;该生命周期会在任何包自有构建完成后调用它。DSH 宿主会导入生成的 `dsh-plugin.mjs` 包装层并将其挂载为子 fiber;该包装层组合静态 skill(技能)与 MCP 所有者,并在声明时组合显式的受信任 Cordis 入口。 - -## 实时更新与失败 - -`dsh-app-boot` 通过一个辅助函数挂载根 Include,并保留其确切的 Loader `Entry`。TUI 和 Web 通过 Cordis HMR(热模块替换)注册 `$DSH_HOME/config.yaml`;无头模式在启动时读取同一文件,但不保留监视器。监视器更新会重新构建 Include 补丁列表,先放置不可变的应用自有补丁,再放置新解析的个人补丁。因此,Web 生成的端口、会话根目录、信任和前端值会在每次个人编辑后保留,除非后续个人补丁有意替换相应配置项。 - -Cordis 会串行处理并合并该确切路径上的变更。Include 与 Loader 以事务方式协调候选配置:成功时提交新源列表;拉取、准备、包装模块导入、格式或子插件失败时拒绝候选配置,并保留或恢复最后一个可用树。HMR 会把捕获的值规范化为 `Error`,记录错误,并广播并行的 `hmr/config-update-failed(filename, error)` 事件;观察者失败不会中断刷新处理。Repository MCP 服务器采用严格启动,因此初始连接、发现或工具注册失败会拒绝候选配置,并构成配置更新失败;非严格的独立 MCP 客户端仍保留其所收束的「插件成功加载但无工具」行为。 - -相同说明符会永久复用同一个缓存版本。HMR 监视配置,而非已缓存的仓库代码;用户必须改变 ref、路径或源列表,才能选择另一个版本。 - -## 信任边界 - -配置仓库即授权该仓库中的包管理器生命周期代码、依赖、显式 `dsh.entry` 和 spawn 的 MCP server 以用户的文件系统权限运行。pnpm 子进程会移除名称中含有 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的环境变量,但这只会减少凭据暴露,并非沙箱。已准备的包装层会校验组合边界和生命周期状态;当来源不受信任时,它无法让 repository 代码变得可安全运行。 - -## 考虑过的替代方案 - -**要求声明 SDK 项目依赖。** 独立应用路径没有可编辑的项目 manifest(元数据清单),因此否决。开发者自有的 SDK 项目仍可使用原生包管理器工作流,这是一项独立能力。 - -**新增 `dsh plugin install` 命令和安装数据库。** 否决,因为个人 Loader 覆盖层已经负责机器本地组合。第二个变更接口和持久注册表会重复配置身份与回滚机制。 - -**由 DSH 包直接解析仓库。** 否决,因为 Git 传输、GitHub 子包选择、生命周期执行和内容存储属于 pnpm 与通用 Loader 缓存,而非 DSH 专用适配器。 - -**监视缓存内容,或自动刷新相同 ref。** 否决,因为一个配置值必须标识一个不可变的已准备版本。后台远端解析会在没有配置差异的情况下改变可执行代码,并使回滚依赖可变的远端状态。 - -**广播 `unknown` 失败载荷。** 在 HMR 边界否决。JavaScript 内部可以抛出任意值,但公开事件始终接收规范化的 `Error`,从而为观察者提供稳定约定,并在需要时把原始值保留为错误原因。 - -## 后果 - -- 添加 `.dsh-plugin/package.json` 的仓库只需一次个人配置编辑即可供独立用户使用,无需改变现有 skill 或 `.mcp.json` 布局。 -- 长时间运行的应用无需重启即可新增、替换或移除已配置版本;被拒绝的候选配置会保留最后一个可用运行时,并产生一个通用 Cordis 事件。 -- 首次使用可能需要 Git/网络访问和准备时间。后续启动会复用这份精确的已准备缓存;在另行制定缓存管理政策之前,旧版本会持续占用磁盘空间。 -- skill 和通用 MCP 定义保留可移植静态适配器,而显式 `dsh.entry` 可以贡献 DSH 原生 Cordis 行为。格式专用的兼容 shim、带 OAuth 的 MCP 定义和插件市场仍有意不提供。 - -## 测试 - -仓库包测试固定源规范化、默认和嵌套 `.dsh-plugin` 路径、缓存根解析、重复项拒绝、已准备包装层加载及资源释放。App-boot 测试通过真实 HMR/Include/Loader 路径驱动确切路径的新增、两类失败、恢复、移除、失败事件及生成补丁保留。一个无密钥 PTY 冒烟测试仅通过个人配置启动已交付的 `dsh` 组合,并从预置的不可变缓存版本中调用一个 skill。 diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.i18n.yaml similarity index 56% rename from .agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml rename to .agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.i18n.yaml index 5985f18a06..da5b77fbd0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md -2026-07-30-config-only-repository-plugins.md: 35327a30e03c51311f634e05ade209ab93ae0155 -2026-07-30-config-only-repository-plugins.zh.md: 5755045560da761b59f7c65e99d551f599c2b5b3 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.md +2026-08-09-remove-repository-plugin.md: 8dd2fe95ac97bc2d8ab50043eb6c516f53d3204c +2026-08-09-remove-repository-plugin.zh.md: 832a69ff206dd91dd312625bd9218e980d890ae2 diff --git a/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.md b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.md new file mode 100644 index 0000000000..8dd2fe95ac --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.md @@ -0,0 +1,43 @@ +# Agent Note: Remove the dedicated repository Plugin path + +Status: implemented + +English | [中文](2026-08-09-remove-repository-plugin.zh.md) + +## Problem + +The repository Plugin path duplicated the profile bundle path for installing and composing third-party packages. It added a `.dsh-plugin` manifest, a generated wrapper, a preparation executable, a second Git/package cache, a Loader builtin, and repository-specific Skill and MCP adapters. Profile bundles already install npm or Git package specifications through the profile package manager, retain normal dependency and lifecycle semantics, and contribute an ordered `cordis.patch.yml` layer that can mount ordinary Cordis Plugins. + +The duplicate path also exposed less configuration than a bundle. Its `repositories` list selected source strings, but the generated wrapper mounted a code entry without a user-supplied Plugin config. Repository-specific preparation therefore added substantial code and CI work without becoming the general external-Plugin distribution mechanism. + +## Decision + +DeepSeek Harness has one standalone external-Plugin distribution path: installable profile bundles. `dsh plugin --profile <name> add <package-or-git-spec>` records the dependency in the profile package, and the installed package declares `dsh.bundle.patch` to contribute its patch layer. The package manager owns source acquisition, versions, dependencies, build lifecycles, and its lockfile. The bundle patch owns Cordis Plugin selection and complete Plugin config. + +The `@deepseek-ai/dsh-repository-plugin` package, `.dsh-plugin` authoring format, `dsh-plugin-prepare` executable, generated wrapper, immutable repository cache, base `repository-plugins` row, and dedicated GitHub acceptance lane are removed. The unused vendored `@cordisjs/plugin-loader/repository` subpath and its bundled pnpm dependency are removed with their only consumer. Existing repository cache directories are inert user data; DSH neither reads nor deletes them. + +Bundles compose existing owners directly. A bundle that contributes Skills mounts `@deepseek-ai/dsh-skill-local`; one that contributes MCP servers mounts `@deepseek-ai/dsh-mcp-client`; native behavior mounts an ordinary compiled Cordis Plugin. These packages retain their own validation, lifecycle, registration, and teardown contracts. No compatibility parser or migration from `.dsh-plugin` is retained under the pre-release compatibility policy. + +This note consolidates the removed repository cache, static format, config-only integration, npm-backed preparation, and trusted code-entry decisions. Their original motivation survives here: standalone users need package-manager-owned external composition, Git and npm dependencies may execute trusted lifecycle code, static Skill and MCP contributions should reuse their existing owners, and source identity belongs in the profile dependency specification and lockfile. Their implementation-specific wrappers, cache generations, and preparation protocol no longer constrain the product. + +## Alternatives considered + +**Keep repository Plugin as a convenience wrapper over bundles.** Rejected because it would preserve two install commands, two manifest formats, and two failure/cache identities for the same package. A convenience that cannot pass ordinary Plugin config also remains less capable than the mechanism it wraps. + +**Teach the repository wrapper to load a bundle patch.** Rejected because the repository cache and preparation protocol would still duplicate profile dependency installation. Bundle packages are already accepted from npm, Git, file, and link specifications through pnpm. + +**Keep the generic Loader repository cache for possible future consumers.** Rejected because it has no current consumer after the package removal and carries a pinned package-manager runtime in a vendored browser-adjacent package. A future need can choose its cache contract from current consumers rather than preserving this one speculatively. + +**Disable repository Plugin but retain its on-disk format for migration.** Rejected under the pre-release stance. Retaining a parser or compatibility loader would keep the removed contract alive without an external compatibility obligation. + +## Consequences + +- Third-party packages use one installation and composition model, with ordinary dependency declarations and full patch-level Plugin config. +- Installing or updating an external bundle is an explicit `dsh plugin` package-manager operation rather than a watched source-list edit. User patch HMR still configures rows contributed by installed bundles. +- `.dsh-plugin` packages and existing repository source-list patches stop working. Their cache files remain removable by the user but are not migrated or automatically deleted. +- The dedicated pnpm runtime, preparation executable, wrapper generator, Git credential CI setup, repository cache, and repository-specific tests disappear. +- Package-relative static assets need a bundle-owned path form so a declarative bundle can point `dsh-skill-local`, `dsh-mcp-client`, or another Plugin at files it ships without custom runtime glue. That capability is owned by the bundle format rather than a repository adapter. + +## Testing + +Static gates reject stale package, config, documentation, graph, and workspace references. The existing `dsh plugin` built-CLI acceptance covers profile initialization, package-manager installation, bundle discovery, and layer reconciliation. Bundle-specific tests own declarative asset-path resolution and real Skill/MCP composition. diff --git a/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.zh.md b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.zh.md new file mode 100644 index 0000000000..832a69ff20 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 移除专用 repository 插件路径 + +Status: implemented + +[English](2026-08-09-remove-repository-plugin.md) | 中文 + +## 问题 + +repository 插件路径与 profile 组合包路径重复实现了第三方包的安装和组合。它增加了 `.dsh-plugin` manifest(元数据清单)、生成的包装层、准备工作可执行文件、第二套 Git/包缓存、Loader 内置项,以及 repository 专用的 skill(技能)和 MCP 适配器。profile 组合包已经能通过 profile 包管理器安装 npm 或 Git 包说明符,保留正常的依赖与生命周期语义,并提供一个有序 `cordis.patch.yml` 层,其中可以挂载普通 Cordis 插件。 + +重复的路径所能提供的配置也少于组合包。其 `repositories` 列表选择源字符串,但生成的包装层挂载代码入口时无法传入用户提供的插件配置。因此,repository 专用的准备流程增加了大量代码和 CI 工作,却没有成为通用的外部插件分发机制。 + +## 决策 + +DeepSeek Harness 只保留一种独立的外部插件分发路径:可安装的 profile 组合包。`dsh plugin --profile <name> add <package-or-git-spec>` 将依赖记录到 profile 包中,安装的包通过声明 `dsh.bundle.patch` 提供自己的 patch 层。包管理器负责获取源、管理版本和依赖、运行构建生命周期,并维护锁文件。组合包 patch 负责选择 Cordis 插件并提供完整的插件配置。 + +移除 `@deepseek-ai/dsh-repository-plugin` 包、`.dsh-plugin` 编写格式、`dsh-plugin-prepare` 可执行文件、生成的包装层、不可变 repository 缓存、base 中的 `repository-plugins` 配置项,以及专用 GitHub 验收流水线。vendor 中未再使用的 `@cordisjs/plugin-loader/repository` 子路径及其随附的 pnpm 依赖,也随唯一消费方一并移除。现有 repository 缓存目录只是不会再产生作用的用户数据;DSH 既不会读取,也不会删除这些目录。 + +组合包直接组合现有归属方。提供 skill 的组合包挂载 `@deepseek-ai/dsh-skill-local`;提供 MCP 服务器的组合包挂载 `@deepseek-ai/dsh-mcp-client`;原生行为则挂载普通的已编译 Cordis 插件。这些包继续保有各自的校验、生命周期、注册和 teardown 契约。根据预发布兼容政策,不保留针对 `.dsh-plugin` 的兼容解析器或迁移机制。 + +本说明整合了已移除的 repository 缓存、静态格式、纯配置集成、由 npm 支持的准备流程和受信任代码入口决策。其原始动机保留于此:独立用户需要由包管理器负责的外部组合方式;Git 和 npm 依赖可以执行受信任的生命周期代码;静态 skill 与 MCP 贡献应复用现有归属方;来源标识应位于 profile 的依赖说明符和锁文件中。相应实现特有的包装层、缓存 generation 和准备协议不再约束产品。 + +## 曾考虑的替代方案 + +**保留 repository 插件,将其作为组合包的便利包装层。** 不予采纳,因为这会为同一个包保留两条安装命令、两种 manifest 格式,以及两套失败/缓存标识。如果一层便利包装不能传递普通的插件配置,其能力仍然不及它所包装的机制。 + +**让 repository 包装层加载组合包 patch。** 不予采纳,因为 repository 缓存和准备协议仍会重复 profile 依赖安装。组合包已经可以通过 pnpm 接受 npm、Git、file 和 link 说明符。 + +**为未来可能出现的消费方保留通用 Loader repository 缓存。** 不予采纳,因为在移除相关包后,它已无当前消费方,却仍让一个 vendor 中与浏览器相邻的包携带固定版本的包管理器运行时。未来若确有需要,可以根据届时的实际消费方选择缓存契约,无需推测性地保留现有契约。 + +**禁用 repository 插件,但保留其磁盘格式以供迁移。** 根据预发布方针,不予采纳。保留解析器或兼容 loader 会在没有外部兼容义务的情况下,让已移除的契约继续存在。 + +## 后果 + +- 第三方包统一使用一种安装与组合模型,采用普通依赖声明和完整的 patch 层插件配置。 +- 安装或更新外部组合包时,必须显式通过 `dsh plugin` 执行包管理器操作,而不是编辑受监听的源列表。用户 patch 的 HMR(热模块替换)仍可配置已安装组合包所提供的配置项。 +- `.dsh-plugin` 包和现有 repository 源列表 patch 停止工作。用户仍可自行删除其缓存文件,但系统不会迁移或自动删除这些文件。 +- 专用 pnpm 运行时、准备工作可执行文件、包装层生成器、Git 凭据 CI 设置、repository 缓存和 repository 专用测试全部消失。 +- 静态资源需要一种由组合包拥有、可相对于包解析的路径形式,使声明式组合包可以将 `dsh-skill-local`、`dsh-mcp-client` 或其他插件指向它随包交付的文件,而无需定制运行时代码。该能力归组合包格式所有,而不是 repository 适配器。 + +## 测试 + +静态门禁会拒绝残留的包、配置、文档、图和 workspace 引用。现有 `dsh plugin` 已构建 CLI(命令行界面)验收测试覆盖 profile 初始化、包管理器安装、组合包发现和层调和。组合包专用测试负责覆盖声明式资源路径解析,以及真实的 skill/MCP 组合。 diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml index a0a0dc998d..7084931b1d 100644 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md -2026-07-17-sdk-follow-up-capabilities.md: 9a17f07139014f95666789e41cacb7af180ef5d8 -2026-07-17-sdk-follow-up-capabilities.zh.md: 2431c5c3d9e45605223d9bb1843e48a3c711759e +2026-07-17-sdk-follow-up-capabilities.md: f14d46a61f5fd3e64067441c2f8340cf94746a79 +2026-07-17-sdk-follow-up-capabilities.zh.md: 2e5efba340d503d2445e408bfc43ee0d6c6bec3c diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md index 9a17f07139..f14d46a61f 100644 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md +++ b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md @@ -47,7 +47,7 @@ The repository ships a thin `SKILL.md` that teaches an agent to construct the st The package manager owns source parsing, version or commit resolution, integrity data, lockfile updates, and any build policy. The SDK does not download or unpack a second copy through giget or pacote. An external plugin remains a dependency under `node_modules`; local plugin scaffolding remains a separate project-creation concern. -This proposal concerns dependencies of developer-owned SDK projects. Standalone app repository caching, its bundled-pnpm policy, and its explicit preparation trust boundary are owned by the [package-manager-native repository cache](../../implemented/architecture/2026-07-30-package-manager-native-repository-cache.md). +This proposal concerns dependencies of developer-owned SDK projects. Standalone apps install external packages as [profile bundles](../../implemented/simplification/2026-08-09-remove-repository-plugin.md), with their profile package manager and lockfile owning acquisition and lifecycle policy. ## Launcher telemetry diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md index 2431c5c3d9..2e5efba340 100644 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md +++ b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md @@ -47,7 +47,7 @@ Create 和 config 使用相同的功能计划形状。create 通过上述命令 包管理器负责来源解析、版本或 commit 解析、`integrity` 数据、lockfile 更新和构建策略。SDK 不再通过 giget 或 pacote 下载、解压第二份副本。外部插件是 `node_modules` 下的依赖;本地插件脚手架仍属于独立的工程创建问题。 -本提案只涉及开发者自有 SDK 工程的依赖。独立应用的仓库缓存、随应用捆绑 pnpm 的政策和显式的准备流程信任边界,均由[包管理器原生仓库缓存](../../implemented/architecture/2026-07-30-package-manager-native-repository-cache.md)负责。 +本提案只涉及开发者自有 SDK 工程的依赖。独立应用将外部包安装为 [profile 组合包](../../implemented/simplification/2026-08-09-remove-repository-plugin.md),由 profile 的包管理器与 lockfile 负责获取和生命周期策略。 ## Launcher 遥测 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eebdafaa48..98604088da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -175,8 +175,6 @@ jobs: DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' DSH_OXLINT_THREADS: '8' DSH_PUBLINT_CONCURRENCY: '8' - DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE: >- - github:${{ github.event.pull_request.head.repo.full_name }}#${{ github.event.pull_request.head.sha }}&path:/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin # Failover halves snapshot concurrency for the shared 64-core VM. DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }} steps: @@ -242,17 +240,6 @@ jobs: if: vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install chromium - - name: Configure private GitHub repository Plugin access - env: - DSH_GITHUB_SOURCE_TOKEN: ${{ github.token }} - run: | - source_config="$RUNNER_TEMP/dsh-github-source.gitconfig" - basic_auth=$(printf 'x-access-token:%s' "$DSH_GITHUB_SOURCE_TOKEN" | base64 | tr -d '\n') - git config --file "$source_config" url.https://github.com/.insteadOf git@github.com: - git config --file "$source_config" --add url.https://github.com/.insteadOf ssh://git@github.com/ - git config --file "$source_config" http.https://github.com/.extraheader "AUTHORIZATION: basic $basic_auth" - echo "GIT_CONFIG_GLOBAL=$source_config" >> "$GITHUB_ENV" - - name: Run compatibility, snapshot, and artifact gates run: pnpm run check:ci:consumers diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fec5de6128..fd10ebc2e0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -80,7 +80,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | -| [`pnpm`](https://github.com/pnpm/pnpm) | MIT | | [`react`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT | | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | diff --git a/apps/cli/composition.md b/apps/cli/composition.md index aef0dc6a7b..9ca8ab91bf 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -12,8 +12,6 @@ flowchart LR cfg --> plugin_dsh_base_timer plugin_dsh_base_hmr["hmr<br/>@cordisjs/plugin-hmr"] cfg --> plugin_dsh_base_hmr - plugin_dsh_base_repository_plugins["repository-plugins<br/>@deepseek-ai/dsh-repository-plugin"] - cfg --> plugin_dsh_base_repository_plugins plugin_dsh_base_llm["llm<br/>@deepseek-ai/dsh-llm"] cfg --> plugin_dsh_base_llm plugin_dsh_base_session["session<br/>@deepseek-ai/dsh-session"] @@ -168,7 +166,6 @@ flowchart LR | --- | --- | | `timer` | `@cordisjs/plugin-timer` | | `hmr` | `@cordisjs/plugin-hmr` | -| `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` | | `llm` | `@deepseek-ai/dsh-llm` | | `session` | `@deepseek-ai/dsh-session` | | `typert` | `@deepseek-ai/dsh-typert-registry` | diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 4b5aed6cd2..6aa39844d0 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 0b5faf8993cd8065fffcfec5f240b0084508db91 -README.zh.md: b9c48c16dd4be186266d30a438329463c31aca70 +README.md: 7f0bd7b0bd50482b3ba7ee95adf6aaf1defb6d28 +README.zh.md: 55f23c5644b8b063ef12fb56fa587ff5d2eb21a2 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 0b5faf8993..7f0bd7b0bd 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -65,11 +65,11 @@ New sessions default to the `workspace-write` permission preset. Bash and filesy ## Shared deployment behavior -The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. +The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision. -The empty `repository-plugins` row lets profile patch layers mount prepared immutable repository Plugin generations. See the [repository Plugin contract](../../../packages/self-modification/repository-plugin/README.md#standalone-app-configuration). The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. +Install external plugin bundles through `dsh plugin --profile <name> add <package-or-git-spec>`. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. ## Source launcher diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index b9c48c16dd..55f23c5644 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -65,11 +65,11 @@ dsh web --dump-config ## 共享部署行为 -基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 +基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。 -空 `repository-plugins` 行让 profile 的 patch 层能够挂载已准备的不可变 repository Plugin generation。参见 [repository Plugin 约定](../../../packages/self-modification/repository-plugin/README.md#standalone-app-configuration)。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。 +通过 `dsh plugin --profile <name> add <package-or-git-spec>` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。 ## 源码启动器 diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json deleted file mode 100644 index 4851f9f1f8..0000000000 --- a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "mcpServers": { - "github_repository": { - "command": "node", - "args": [ - "lib/mcp-server.mjs" - ] - } - } -} diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json deleted file mode 100644 index 6871a4d053..0000000000 --- a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "dsh-github-repository-plugin-e2e-fixture", - "version": "0.0.0", - "private": true, - "type": "module", - "files": [ - "lib", - "dsh-plugin.mjs", - "dsh-plugin-assets" - ], - "scripts": { - "prepack": "tsc --noEmit && tsdown src/plugin.ts src/mcp-server.ts --no-config --tsconfig tsconfig.json --out-dir lib --platform node --target es2024 --clean && dsh-plugin-prepare" - }, - "dsh": { - "skills": [ - "../skills" - ], - "mcpServers": "./.mcp.json", - "entry": "./lib/plugin.mjs" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "1.29.0" - }, - "devDependencies": { - "@deepseek-ai/dsh-repository-plugin": "0.0.1", - "cordis": "4.0.0-rc.7", - "tsdown": "0.22.2", - "typescript": "6.0.3" - } -} diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts deleted file mode 100644 index 78a3e008f4..0000000000 --- a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' - -// The repository root's linter cannot resolve this independently installed -// Git-package dependency; the package's prepack tsc validates the SDK types. -/* oxlint-disable typescript/no-unsafe-assignment, typescript/no-unsafe-call, typescript/no-unsafe-member-access */ -const server = new McpServer({ - name: 'github-repository-plugin-e2e', - version: '0.0.0', -}) - -server.registerTool('proof', { - description: 'Proves that an MCP server compiled from the exact GitHub repository package is active.', - inputSchema: {}, -}, async () => ({ - content: [{ type: 'text', text: 'MCP_FROM_GITHUB_REPOSITORY' }], -})) - -await server.connect(new StdioServerTransport()) diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts deleted file mode 100644 index 5106f71b2f..0000000000 --- a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Context } from 'cordis' - -const PROOF_TOOL_NAME = 'mcp__github_repository__proof' - -interface TextBlock { - readonly type: 'text' - readonly text: string -} - -interface ToolExecution { - readonly name: string -} - -interface ToolResult { - readonly isError: boolean - readonly content: readonly TextBlock[] -} - -type PostDecision = - | { readonly kind: 'accept'; readonly content?: readonly TextBlock[]; readonly value?: unknown; readonly additionalContexts?: readonly unknown[] } - | { readonly kind: 'block'; readonly feedback: readonly TextBlock[] } - -type PostListener = ( - execution: ToolExecution, - result: ToolResult, - next: () => Promise<PostDecision>, -) => Promise<PostDecision> - -type DshContext = Context & { - on(event: 'tools/post-execute', listener: PostListener): () => void -} - -/** Cordis plugin name used by the repository acceptance fixture. */ -export const name = 'github-repository-typescript-proof' - -/** DSH tool registry required by the post-execute contribution. */ -export const inject = ['tools'] - -/** - * Append a marker after the repository MCP proof tool succeeds. - * @param ctx - trusted DSH Cordis context supplied to the repository package. - */ -export function apply(ctx: Context): void { - const dsh = ctx as DshContext - dsh.on('tools/post-execute', async (execution, result, next): Promise<PostDecision> => { - const decision = await next() - if (execution.name !== PROOF_TOOL_NAME || result.isError || decision.kind !== 'accept' || Object.hasOwn(decision, 'value')) { - return decision - } - return { - kind: 'accept', - content: [ - ...(decision.content ?? result.content), - { type: 'text', text: 'TS_PLUGIN_FROM_GITHUB_REPOSITORY' }, - ], - ...decision.additionalContexts === undefined ? {} : { additionalContexts: decision.additionalContexts }, - } - }) -} diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json deleted file mode 100644 index 22d9301cfb..0000000000 --- a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2024", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": [ - "src/**/*.ts" - ] -} diff --git a/apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md b/apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md deleted file mode 100644 index eae668a4f9..0000000000 --- a/apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: github-source-proof -description: Proves that dsh installed a private repository Plugin from an exact GitHub source. ---- - -This skill exists only in the GitHub repository source fixture. diff --git a/apps/cli/tests/github-repository-plugin.built.e2e.ts b/apps/cli/tests/github-repository-plugin.built.e2e.ts deleted file mode 100644 index 7bb4706dd7..0000000000 --- a/apps/cli/tests/github-repository-plugin.built.e2e.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { createHash } from 'node:crypto' -import { cpSync, existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import { createServer } from 'node:http' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' -import { execa } from 'execa' -import { describe, expect, it } from 'vitest' - -const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) -const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -const repositoryPluginPackage = join(repoRoot, 'packages/self-modification/repository-plugin') -const releasePackageNames = new Set(globSync([ - 'vendor/*/package.json', - 'packages/*/*/package.json', - 'apps/*/package.json', -], { cwd: repoRoot }).map((filename) => { - const manifest = JSON.parse(readFileSync(join(repoRoot, filename), 'utf8')) as Record<string, unknown> - if (typeof manifest.name !== 'string') throw new Error(`workspace package name is missing: ${filename}`) - return manifest.name -})) -const source = process.env.DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE -const required = process.env.DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E === '1' -const enabled = required || source !== undefined - -interface PublishedPackageRegistry { - url: string - requests: string[] - close(): Promise<void> -} - -function publishedManifest(): Record<string, unknown> { - const manifest = JSON.parse(readFileSync(join(repositoryPluginPackage, 'package.json'), 'utf8')) as Record<string, unknown> - const version = manifest.version - if (typeof version !== 'string') throw new Error('repository Plugin package version is missing') - Reflect.deleteProperty(manifest, 'private') - for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { - const dependencies = manifest[field] - if (typeof dependencies !== 'object' || dependencies === null || Array.isArray(dependencies)) continue - const entries = dependencies as Record<string, unknown> - for (const name of Object.keys(entries)) { - if (releasePackageNames.has(name)) { - entries[name] = version - } - } - } - return manifest -} - -async function startPublishedPackageRegistry(root: string): Promise<PublishedPackageRegistry> { - const staging = join(root, 'published-repository-plugin') - const artifacts = join(root, 'npm-registry-artifacts') - mkdirSync(staging) - mkdirSync(artifacts) - cpSync(join(repositoryPluginPackage, 'lib'), join(staging, 'lib'), { recursive: true }) - for (const filename of ['README.md', 'README.zh.md', 'README.i18n.yaml']) { - cpSync(join(repositoryPluginPackage, filename), join(staging, filename)) - } - cpSync(join(repoRoot, 'LICENSE'), join(staging, 'LICENSE')) - const manifest = publishedManifest() - writeFileSync(join(staging, 'package.json'), `${JSON.stringify(manifest, undefined, 2)}\n`) - const packed = await execa('pnpm', ['pack', '--pack-destination', artifacts], { - cwd: staging, - reject: false, - }) - if (packed.exitCode !== 0) { - throw new Error(`failed to pack the simulated published prepare package:\n${packed.stderr}\n${packed.stdout}`) - } - const tarballs = readdirSync(artifacts).filter(filename => filename.endsWith('.tgz')) - if (tarballs.length !== 1) throw new Error(`expected one simulated published tarball, found ${tarballs.length}`) - const tarball = readFileSync(join(artifacts, tarballs[0]!)) - const name = manifest.name as string - const version = manifest.version as string - const requests: string[] = [] - let registryUrl = '' - const server = createServer((request, response) => { - const path = decodeURIComponent(new URL(request.url ?? '/', registryUrl).pathname) - requests.push(`${request.method ?? 'GET'} ${path}`) - if (path === `/${name}`) { - const metadata = { - name, - 'dist-tags': { latest: version }, - versions: { - [version]: { - ...manifest, - dist: { - tarball: `${registryUrl}${name}/-/${name.split('/').at(-1)}-${version}.tgz`, - shasum: createHash('sha1').update(tarball).digest('hex'), - integrity: `sha512-${createHash('sha512').update(tarball).digest('base64')}`, - }, - }, - }, - } - response.writeHead(200, { 'content-type': 'application/json' }) - response.end(JSON.stringify(metadata)) - return - } - if (path === `/${name}/-/${name.split('/').at(-1)}-${version}.tgz`) { - response.writeHead(200, { - 'content-type': 'application/octet-stream', - 'content-length': String(tarball.length), - }) - response.end(tarball) - return - } - response.writeHead(404, { 'content-type': 'application/json' }) - response.end(JSON.stringify({ error: 'not found' })) - }) - await new Promise<void>((resolve, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', resolve) - }) - const address = server.address() - if (address === null || typeof address === 'string') throw new Error('simulated npm registry did not expose a TCP address') - registryUrl = `http://127.0.0.1:${address.port}/` - return { - url: registryUrl, - requests, - close: () => new Promise<void>((resolve, reject) => { - server.close((error) => { if (error === undefined) resolve(); else reject(error) }) - }), - } -} - -describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => { - it('installs the published prepare dependency, then builds and runs skill, MCP, and TypeScript Plugin contributions from a private exact GitHub source', async () => { - expect(existsSync(dshBin), 'the repository Plugin acceptance must run the built dsh entry').toBe(true) - expect(source, 'DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE is required by this CI lane').toMatch( - /^github:[^/\s#&]+\/[^/\s#&]+#[0-9a-f]{40}&path:\/.*\/\.dsh-plugin$/u, - ) - - const apiKey = 'github-repository-plugin-e2e-key' - const server = await startMockLlmServer({ - sequence: ['tool_call_success', 'success'], - apiKey, - toolName: 'mcp__github_repository__proof', - toolArguments: '{}', - successText: 'trusted GitHub repository package reached dsh run', - }) - const home = mkdtempSync(join(tmpdir(), 'dsh-github-repository-plugin-')) - const registry = await startPublishedPackageRegistry(home) - const npmrc = join(home, 'npmrc') - writeFileSync(npmrc, `@deepseek-ai:registry=${registry.url}\n`) - const hostBin = join(home, 'host-bin') - mkdirSync(hostBin) - writeFileSync(join(hostBin, 'dsh-plugin-prepare'), [ - '#!/bin/sh', - 'echo "host PATH supplied dsh-plugin-prepare instead of the declared npm dependency" >&2', - 'exit 91', - '', - ].join('\n'), { mode: 0o700 }) - const patch = join(home, 'github-repository-plugin.cordis.patch.yml') - writeFileSync(patch, [ - '- id: repository-plugins', - ' config:', - ' repositories:', - ` - ${JSON.stringify(source)}`, - '- id: session-title-llm', - ' disabled: true', - '', - ].join('\n')) - - try { - const result = await execa(process.execPath, [ - dshBin, - 'run', - '--patch', - patch, - 'prove the private GitHub repository Plugin is active', - ], { - cwd: repoRoot, - input: '', - timeout: 180_000, - killSignal: 'SIGKILL', - reject: false, - env: { - ...process.env, - DSH_HOME: home, - DSH_TELEMETRY_DISABLED: '1', - DEEPSEEK_API_KEY: apiKey, - DEEPSEEK_BASE_URL: server.baseURL, - NPM_CONFIG_USERCONFIG: npmrc, - // A warm runner cache could satisfy the exact tarball without - // contacting this test's registry, which would stop proving the - // unpublished package was installed through the simulated release. - PNPM_CONFIG_CACHE_DIR: join(home, 'pnpm-cache'), - PNPM_CONFIG_STORE_DIR: join(home, 'pnpm-store'), - PATH: process.env.PATH === undefined ? hostBin : `${hostBin}${delimiter}${process.env.PATH}`, - }, - }) - if (result.timedOut) { - throw new Error(`dsh GitHub repository Plugin run did not exit within 180s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) - } - expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}`).toBe(0) - expect(result.stdout).toBe('trusted GitHub repository package reached dsh run') - expect(server.requests).toHaveLength(2) - const runtimeDiagnostic = `${result.stderr}\nstdout:\n${result.stdout}` - expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin') - expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin/-/dsh-repository-plugin-0.0.1.tgz') - const firstRequest = JSON.stringify(server.requests[0]!.body) - const secondRequest = JSON.stringify(server.requests[1]!.body) - expect(firstRequest, runtimeDiagnostic).toContain( - 'Proves that dsh installed a private repository Plugin from an exact GitHub source.', - ) - expect(firstRequest, runtimeDiagnostic).toContain('mcp__github_repository__proof') - expect(firstRequest, runtimeDiagnostic).toContain('Proves that an MCP server compiled from the exact GitHub repository package is active.') - expect(secondRequest, runtimeDiagnostic).toContain('MCP_FROM_GITHUB_REPOSITORY') - expect(secondRequest, runtimeDiagnostic).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY') - - const cacheRoot = join(home, 'cache', 'repository-plugins') - const generations = readdirSync(cacheRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()) - expect(generations).toHaveLength(1) - const installed = join(cacheRoot, generations[0]!.name, 'node_modules', 'repository') - const manifest = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8')) as Record<string, unknown> - expect(manifest).toMatchObject({ - name: 'dsh-github-repository-plugin-e2e-fixture', - private: true, - scripts: { - prepack: 'tsc --noEmit && tsdown src/plugin.ts src/mcp-server.ts --no-config --tsconfig tsconfig.json --out-dir lib --platform node --target es2024 --clean && dsh-plugin-prepare', - }, - dsh: { - skills: ['../skills'], - mcpServers: './.mcp.json', - entry: './lib/plugin.mjs', - }, - dependencies: { - '@modelcontextprotocol/sdk': '1.29.0', - }, - devDependencies: { - '@deepseek-ai/dsh-repository-plugin': '0.0.1', - cordis: '4.0.0-rc.7', - tsdown: '0.22.2', - typescript: '6.0.3', - }, - }) - expect(readFileSync(join(installed, 'dsh-plugin-assets/skills/0/github-source-proof/SKILL.md'), 'utf8')) - .toContain('This skill exists only in the GitHub repository source fixture.') - expect(readFileSync(join(installed, 'dsh-plugin-assets/.mcp.json'), 'utf8')).toContain('lib/mcp-server.mjs') - expect(readFileSync(join(installed, 'lib/plugin.mjs'), 'utf8')).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY') - expect(readFileSync(join(installed, 'lib/mcp-server.mjs'), 'utf8')).toContain('MCP_FROM_GITHUB_REPOSITORY') - expect(existsSync(join(installed, 'src'))).toBe(false) - const installedRequire = createRequire(join(installed, 'lib/mcp-server.mjs')) - expect(existsSync(installedRequire.resolve('@modelcontextprotocol/sdk/server/mcp.js'))).toBe(true) - const wrapper = readFileSync(join(installed, 'dsh-plugin.mjs'), 'utf8') - expect(wrapper).toContain('dsh-repository-plugin') - expect(wrapper).toContain('await import(manifest.entry)') - expect(wrapper).toContain('"entry":"./lib/plugin.mjs"') - } finally { - await server.close() - await registry.close() - rmSync(home, { recursive: true, force: true }) - } - }, 190_000) -}) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 471680f92d..842b96d950 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1109,7 +1109,7 @@ export interface StreamableHttpConfig { } ``` -Source: [`packages/mcp/mcp-client/src/index.ts:100`](../packages/mcp/mcp-client/src/index.ts) +Source: [`packages/mcp/mcp-client/src/index.ts:98`](../packages/mcp/mcp-client/src/index.ts) ## `@deepseek-ai/dsh-permission` @@ -1306,22 +1306,6 @@ export interface Config { Source: [`packages/guard/repeat-tool-guard/src/index.ts:28`](../packages/guard/repeat-tool-guard/src/index.ts) -## `@deepseek-ai/dsh-repository-plugin` - -Requires: `loader` - -```ts config-catalog -/** Repository Plugin runtime and source-list configuration. */ -export interface Config { - /** GitHub repository sources with explicit refs and optional `.dsh-plugin` subpaths. */ - repositories?: string[] - /** Persistent generation cache; defaults to `$DSH_HOME/cache/repository-plugins`. */ - cacheDir?: string -} -``` - -Source: [`packages/self-modification/repository-plugin/src/index.ts:44`](../packages/self-modification/repository-plugin/src/index.ts) - ## `@deepseek-ai/dsh-sandbox-local` ```ts config-catalog diff --git a/docs/module-graph.md b/docs/module-graph.md index b596cb5649..9f2780c57a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -253,7 +253,6 @@ flowchart TD pkg_telemetry["telemetry"] end subgraph group_self_modification["packages/self-modification"] - pkg_repository_plugin["repository-plugin"] pkg_tool_cordis["tool-cordis"] end subgraph group_session["packages/session"] @@ -1069,10 +1068,6 @@ flowchart TD pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent - pkg_repository_plugin --> pkg_invariants - pkg_repository_plugin --> pkg_mcp_client - pkg_repository_plugin --> pkg_paths - pkg_repository_plugin --> pkg_skill_local pkg_tool_ralph --> pkg_agent pkg_tool_ralph --> pkg_invariants pkg_tool_ralph --> pkg_llm @@ -1430,7 +1425,6 @@ flowchart TD | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index dbab9ce2f3..f61b6daeb7 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -284,7 +284,7 @@ Source: [`packages/self-modification/tool-cordis/src/index.ts`](../packages/self ### `cordis_mount` -Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. +Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. ```json { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 7908f0e71b..80e66c8c47 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -5,7 +5,7 @@ {"type":"subagent/descriptor","seq":3,"time":1785821418091,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","seq":4,"time":1785730458555,"data":{"turn":1,"step":1}} {"type":"user/message","seq":5,"time":1785730458555,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730458555,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"f9a2d1b6-8f23-43a5-8702-d413fed40990"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730458555,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"c9c613c6-adfc-4ba3-b7d8-322bcb26a668"},"surfaceOp":"append"} {"type":"session/title","seq":7,"time":1785730458555,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} {"type":"request/header","seq":8,"time":1785730458555,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":9,"time":1785730458555,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index adf877c24b..ce189804c5 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -5,7 +5,7 @@ {"type":"subagent/descriptor","seq":3,"time":1785821418270,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","seq":4,"time":1785730458703,"data":{"turn":1,"step":1}} {"type":"user/message","seq":5,"time":1785730458703,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730458703,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"dfbcd587-db47-4c3d-bbe9-8c031b215fc3"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730458703,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"fee5dd01-66b5-4856-9fc8-f375e0c37ae1"},"surfaceOp":"append"} {"type":"session/title","seq":7,"time":1785730458703,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} {"type":"request/header","seq":8,"time":1785730458703,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":9,"time":1785730458703,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index d6935b6c98..d16b9bcccb 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821417919,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498801761,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"},"role":"user","id":"6e45782a-31be-4ba7-8c4a-7411a2027e36"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730458430,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"9f38e2b8-1d4e-4c90-8896-00aa42307ea7"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730458430,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"f8ef8963-7ba4-4836-8cef-7321aa1d30cf"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730458430,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498801765,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730458431,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 2014dc54e3..b613fbac76 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -60,7 +60,7 @@ interface ToolArgsMap { /** Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events". */ name?: string; } & Record<string, JsonValue>; - /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */ + /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */ cordis_mount: { /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */ code: string; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 403d3fbc6c..c979e1db87 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -72,7 +72,7 @@ }, { "name": "cordis_mount", - "description": "Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.", + "description": "Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.", "parameters": { "type": "object", "properties": { diff --git a/examples/headless-agent/tests/fixtures/cli.cordis.yml b/examples/headless-agent/tests/fixtures/cli.cordis.yml index 21a26bcd73..b9df4d29f7 100644 --- a/examples/headless-agent/tests/fixtures/cli.cordis.yml +++ b/examples/headless-agent/tests/fixtures/cli.cordis.yml @@ -1,9 +1,6 @@ - id: cli-mock-llm name: './cli-mock-llm.ts' -- id: repository-plugin-fixture - name: './repository-plugin/load.mjs' - - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md deleted file mode 100644 index e24104e79f..0000000000 --- a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: repository-fixture -description: Repository fixture skill. ---- - -Static instructions from a prepared repository plugin. diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs b/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs deleted file mode 100644 index 29aa97c5ec..0000000000 --- a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs +++ /dev/null @@ -1,19 +0,0 @@ -// Generated by dsh-plugin-prepare. Do not edit. -const manifest = {"name":"headless-repository-fixture","skills":["dsh-plugin-assets/skills/0"]} -// Value mirror: Cordis const enum FiberState.ACTIVE; keep aligned with dsh-repository-plugin source.ts. -const FIBER_ACTIVE = 2 -export const name = "headless-repository-fixture" -export const inject = ["loader","skills"] -async function mount(ctx, plugin, label, config) { - const fiber = ctx.plugin(plugin, config) - await fiber - if (fiber.state !== FIBER_ACTIVE) { - const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) - throw new Error(`${label} did not activate (waiting for services: ${missing.join(', ') || 'unknown'})`) - } -} -export async function apply(ctx) { - const runtime = ctx.loader.builtins["dsh-repository-plugin"] - if (runtime === undefined) throw new Error("missing Cordis builtin dsh-repository-plugin") - await mount(ctx, runtime, 'repository Plugin runtime', { baseUrl: import.meta.url, manifest }) -} diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/load.mjs b/examples/headless-agent/tests/fixtures/repository-plugin/load.mjs deleted file mode 100644 index 90f759876a..0000000000 --- a/examples/headless-agent/tests/fixtures/repository-plugin/load.mjs +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Keyless fixture owner that mounts the runtime before its prepared wrapper. - * Cordis starts sibling Loader entries concurrently, so row order is not a dependency edge. - */ -import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin' -import * as PreparedPlugin from './dsh-plugin.mjs' - -export const name = 'headless-repository-fixture-loader' - -export async function apply(ctx) { - await ctx.plugin(RepositoryPlugin) - await ctx.plugin(PreparedPlugin) -} diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index f855c958bf..9eee7cd0ab 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -1,17 +1,10 @@ -import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { readFile, readdir } from 'node:fs/promises' import { zstdDecompress } from 'node:zlib' import { promisify } from 'node:util' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { - PREPARED_ENTRY_FILENAME, - REPOSITORY_PLUGIN_PREPARE_COMMAND, - REPOSITORY_PLUGIN_PACKAGE_NAME, - prepareDshPlugin, -} from '@deepseek-ai/dsh-repository-plugin' import type { SessionEvent } from '@deepseek-ai/dsh-session' const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) @@ -44,16 +37,6 @@ describe('headless-agent keyless smoke', () => { const result = lines.at(-1) expect(stderr).toBe('') expect(events.some(event => event.type === 'tool/call' && event.data.name === 'bash')).toBe(true) - const catalogMessage = events.find(event => event.type === 'user/message' - && event.data.source.kind === 'skill-catalog') - const catalog = catalogMessage?.type === 'user/message' - ? catalogMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('\n') - : '' - expect(catalog.split('\n').find(line => line.includes('repository-fixture'))).toMatchInlineSnapshot( - ` - "- \`repository-fixture\`: Repository fixture skill." - `, - ) const toolResult = events.find(event => event.type === 'tool/result') expect(JSON.stringify(toolResult)).toContain('CLI_TOOL_ROUND_TRIP') expect(result).toMatchObject({ @@ -63,30 +46,4 @@ describe('headless-agent keyless smoke', () => { expect(String(result?.['output'])).toContain('CLI_TOOL_ROUND_TRIP') expect(persistedHeader).toMatchObject({ type: 'session' }) }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - it('keeps the checked-in prepared wrapper identical to the generator output for its manifest', async () => { - // The fixture claims "Generated by dsh-plugin-prepare"; this pin makes the - // claim true — a wrapper-template change fails here until the fixture is - // regenerated, so the assembled smoke can never exercise stale generated fields. - const fixture = fileURLToPath(new URL('./fixtures/repository-plugin/', import.meta.url)) - const root = await mkdtemp(join(tmpdir(), 'dsh-fixture-drift-')) - try { - const plugin = join(root, '.dsh-plugin') - await mkdir(plugin, { recursive: true }) - await cp(join(fixture, 'dsh-plugin-assets/skills/0'), join(root, 'skills'), { recursive: true }) - await writeFile(join(plugin, 'package.json'), `${JSON.stringify({ - name: 'headless-repository-fixture', - version: '0.0.0', - scripts: { prepack: REPOSITORY_PLUGIN_PREPARE_COMMAND }, - devDependencies: { [REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1' }, - dsh: { skills: ['../skills'] }, - }, undefined, 2)}\n`) - await prepareDshPlugin(plugin) - const generated = await readFile(join(plugin, PREPARED_ENTRY_FILENAME), 'utf8') - const checkedIn = await readFile(join(fixture, PREPARED_ENTRY_FILENAME), 'utf8') - expect(checkedIn).toBe(generated) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) }) diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 44cbf0e360..9d88e50f42 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,18 +1,18 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fc62f9e7-b8f6-441f-9ee8-17f1f9e4feca"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"39adeff2-d71e-402c-b2d4-37034ef34266"}]}} {"type":"turn/start","seq":1,"time":1785821454445,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454445,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","seq":3,"time":1785821454466,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","seq":4,"time":1785730501506,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fc62f9e7-b8f6-441f-9ee8-17f1f9e4feca"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"39adeff2-d71e-402c-b2d4-37034ef34266"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730501506,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record<string, JsonValue>;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record<string, JsonValue>;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record<string, JsonValue>;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record<string, JsonValue>;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record<string, JsonValue>)[];\n } & Record<string, JsonValue>;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record<string, JsonValue>;\n } & Record<string, JsonValue>;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":7,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record<string, JsonValue>;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record<string, JsonValue>;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record<string, JsonValue>;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record<string, JsonValue>;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record<string, JsonValue>)[];\n } & Record<string, JsonValue>;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record<string, JsonValue>;\n } & Record<string, JsonValue>;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730501507,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} {"type":"assistant/chunk","seq":12,"time":1785498583897,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":13,"time":1785730501507,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cac680cf-1d70-4fb2-91a3-da1e3a317d2e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","seq":14,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5a6c9f8f-37db-4fb5-bedd-29c6b69d815a"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"step/end","seq":15,"time":1785730501507,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":16,"time":1785730501507,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 6988595618..984e662d91 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,18 +1,18 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"093bfc20-c6fc-4573-b172-2c6ca40c188b"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"97e45fc6-61b8-443f-bd01-7fb0b8263eba"}]}} {"type":"turn/start","seq":1,"time":1785821454599,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454599,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","seq":3,"time":1785821454618,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","seq":4,"time":1785730501645,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"093bfc20-c6fc-4573-b172-2c6ca40c188b"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"97e45fc6-61b8-443f-bd01-7fb0b8263eba"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730501645,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record<string, JsonValue>;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record<string, JsonValue>;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record<string, JsonValue>;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record<string, JsonValue>;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record<string, JsonValue>)[];\n } & Record<string, JsonValue>;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record<string, JsonValue>;\n } & Record<string, JsonValue>;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":7,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record<string, JsonValue>;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record<string, JsonValue>;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record<string, JsonValue>;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record<string, JsonValue>;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record<string, JsonValue>)[];\n } & Record<string, JsonValue>;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record<string, JsonValue>;\n } & Record<string, JsonValue>;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730501646,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} {"type":"assistant/chunk","seq":12,"time":1785498584067,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":13,"time":1785730501646,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2b31dae5-8939-44e1-bbcd-9f64aa637d76"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","seq":14,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"64227683-7d01-49bd-aa9c-16c64e5b6a66"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"step/end","seq":15,"time":1785730501646,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":16,"time":1785730501646,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 646110b6d9..f479122d66 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,20 +1,20 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"286bdfe0-d81d-4511-a0b1-b706dc025a64"}]}} {"type":"turn/start","seq":1,"time":1785821454304,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454304,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"286bdfe0-d81d-4511-a0b1-b706dc025a64"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1785498583779,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record<string, JsonValue>;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record<string, JsonValue>;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record<string, JsonValue>;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record<string, JsonValue>;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record<string, JsonValue>)[];\n } & Record<string, JsonValue>;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record<string, JsonValue>;\n } & Record<string, JsonValue>;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record<string, JsonValue>;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record<string, JsonValue>;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record<string, JsonValue>;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record<string, JsonValue>;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record<string, JsonValue>;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record<string, JsonValue>;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record<string, JsonValue>;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record<string, JsonValue>;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record<string, JsonValue>;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record<string, JsonValue>;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record<string, JsonValue>;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record<string, JsonValue>;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record<string, JsonValue>;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record<string, JsonValue>)[];\n } & Record<string, JsonValue>;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record<string, JsonValue>;\n } & Record<string, JsonValue>;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record<string, JsonValue>;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785730501403,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":8,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":10,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1785498583784,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1785730501404,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e65c0ebe-8e3d-44c0-833f-68efcbc0acb5"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"93f733e0-c190-4a3d-80a4-f534ddf325e9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1785730501404,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"abb8ecee-cb03-4a66-9477-38a52458ab05"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"10d97131-ca23-4af0-ab53-7aa386c08c12"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1785730501413,"data":{"turn":1,"step":1}} {"type":"step/start","seq":17,"time":1785730501423,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -22,11 +22,11 @@ {"type":"assistant/chunk","seq":20,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} {"type":"assistant/chunk","seq":21,"time":1785498583804,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":22,"time":1785730501424,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc95327-3ce1-49ea-8a92-b17e450cc455"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8b483937-d8d4-409e-9698-6adc1dbb4992"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","seq":24,"time":1785730501424,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} {"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} {"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} -{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"d75c7d03-cbbc-4260-ba40-8c210a3b5bbe"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"8556d6ea-03a1-4965-8d5b-90361a578a2c"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1785730501475,"data":{"turn":1,"step":2}} {"type":"step/start","seq":29,"time":1785730501483,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":30,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -34,9 +34,9 @@ {"type":"assistant/chunk","seq":32,"time":1785037378923,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} {"type":"assistant/chunk","seq":33,"time":1785498583869,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":34,"time":1785730501484,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ba4958e9-231c-437f-a2fc-7a13f392d3ba"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} +{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4507f38e-aabb-424d-921f-fcb4fc270711"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} {"type":"tool/call","seq":36,"time":1785730501484,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"b9ebb37d-e565-4882-95b0-5343da1d68d8"}},"sourceEventSeqs":[36],"surfaceOp":"append"} +{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"001fd41e-56fc-4a5b-9180-78f4120f5b85"}},"sourceEventSeqs":[36],"surfaceOp":"append"} {"type":"step/end","seq":38,"time":1785730501508,"data":{"turn":1,"step":3}} {"type":"step/start","seq":39,"time":1785730501521,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -44,9 +44,9 @@ {"type":"assistant/chunk","seq":42,"time":1785037378946,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} {"type":"assistant/chunk","seq":43,"time":1785498583919,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":44,"time":1785730501522,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4757f4b9-9bde-488b-a54a-1bdea55dd15f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} +{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"59ccdc72-932c-4dc9-83a9-56d1b05fb930"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} {"type":"tool/call","seq":46,"time":1785730501522,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} -{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"35baa460-54ff-4fa1-ba9d-66b6661f84e9"}},"sourceEventSeqs":[46],"surfaceOp":"append"} +{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"00e81f53-1914-4236-8056-07e7592dc4df"}},"sourceEventSeqs":[46],"surfaceOp":"append"} {"type":"step/end","seq":48,"time":1785730501648,"data":{"turn":1,"step":4}} {"type":"step/start","seq":49,"time":1785730501660,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -54,9 +54,9 @@ {"type":"assistant/chunk","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":53,"time":1785498584085,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":54,"time":1785730501661,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"739166e2-ed48-4df2-a9a5-207f34058030"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} +{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d4c2df8c-860c-45c0-976c-2e92d1700284"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} {"type":"tool/call","seq":56,"time":1785730501661,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"98b05c06-cb77-41a9-8310-324bc72fc7a0"}},"sourceEventSeqs":[56],"surfaceOp":"append"} +{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"134abbb7-24c4-48d6-93b2-f0be788b5f7d"}},"sourceEventSeqs":[56],"surfaceOp":"append"} {"type":"step/end","seq":58,"time":1785730501668,"data":{"turn":1,"step":5}} {"type":"step/start","seq":59,"time":1785730501678,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -64,6 +64,6 @@ {"type":"assistant/chunk","seq":62,"time":1785037379541,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} {"type":"assistant/chunk","seq":63,"time":1785498584102,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":64,"time":1785730501679,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0a4ca8f2-92c1-4dbc-beb8-923b8791c298"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} +{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6157c734-e26c-457b-9d72-92b2f9bcf8a0"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} {"type":"step/end","seq":66,"time":1785730501679,"data":{"turn":1,"step":6}} {"type":"turn/end","seq":67,"time":1785730501679,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/package.json b/examples/package.json index 48ac39f5ca..4e684d9d80 100644 --- a/examples/package.json +++ b/examples/package.json @@ -50,7 +50,6 @@ "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-pwsh-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", - "@deepseek-ai/dsh-repository-plugin": "workspace:*", "@deepseek-ai/dsh-sandbox": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/knip.json b/knip.json index 249d21d857..512ec350e4 100644 --- a/knip.json +++ b/knip.json @@ -717,20 +717,6 @@ "@deepseek-ai/.+" ] }, - "apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin": { - "entry": [ - "src/*.ts" - ], - "project": [ - "src/**/*.ts" - ], - "ignoreDependencies": [ - "@deepseek-ai/dsh-repository-plugin" - ], - "ignoreBinaries": [ - "dsh-plugin-prepare" - ] - }, "packages/client/modules": { "entry": [ "tests/**/*.spec.ts" diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index d77d9ed870..b4c191a1d0 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: c246534a26cd7297f2ba8885099c8b517a5dc2b0 -README.zh.md: 4139874d3ebbf82fad8680a3721a1cf35a553706 +README.md: 19d6e5ba7b554f59bd66e213f8a53389761fc735 +README.zh.md: 17f58a2922e9019af054b0dccb6c4d9199fd1a9d diff --git a/packages/README.md b/packages/README.md index c246534a26..19d6e5ba7b 100644 --- a/packages/README.md +++ b/packages/README.md @@ -38,7 +38,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr | [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface | | [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface | -| [`self-modification/`](self-modification/README.md) | Agent runtime self-modification: live plugin/service inspection, model-written plugin mount/unmount ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)), restricted repository Plugin loading | Product — stable surface | +| [`self-modification/`](self-modification/README.md) | Agent runtime self-modification: live plugin/service inspection and model-written plugin mount/unmount ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 4139874d3e..17f58a2922 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -38,7 +38,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通 | [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定接口 | | [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 | | [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 | -| [`self-modification/`](self-modification/README.md) | agent 运行时自修改:实时插件/服务检查、模型所写插件挂载/卸载([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md))、受限仓库插件加载 | 产品:稳定接口 | +| [`self-modification/`](self-modification/README.md) | agent 运行时自修改:实时插件/服务检查和模型所写插件挂载/卸载([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定接口 | | [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定接口 | | [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、日志支持的标题、会话上报 | 产品:稳定接口 | | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定接口 | diff --git a/packages/boot/app-boot/tests/repository-cache.spec.ts b/packages/boot/app-boot/tests/repository-cache.spec.ts deleted file mode 100644 index 71aa90a440..0000000000 --- a/packages/boot/app-boot/tests/repository-cache.spec.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { execFile } from 'node:child_process' -import { createHash } from 'node:crypto' -import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { promisify } from 'node:util' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { BUNDLED_PNPM_VERSION, RepositoryCache, type RepositoryInstall } from '@cordisjs/plugin-loader/repository' - -const execFileAsync = promisify(execFile) -const roots: string[] = [] - -/** Normalize Git's platform checkout line endings for source-content assertions. */ -const lf = (text: string): string => text.replace(/\r\n/g, '\n') - -async function temporaryRoot(name: string): Promise<string> { - const root = await mkdtemp(join(tmpdir(), `cordis-${name}-`)) - roots.push(root) - return root -} - -async function fakePackage(directory: string): Promise<void> { - const target = join(directory, 'node_modules', 'repository') - await mkdir(target, { recursive: true }) - await writeFile(join(target, 'package.json'), '{"name":"fixture"}\n') -} - -afterEach(async () => { - vi.unstubAllEnvs() - await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) -}) - -describe('RepositoryCache', () => { - it('single-flights and permanently reuses an exact specifier', async () => { - const root = await temporaryRoot('repository-cache') - const calls: string[] = [] - const install: RepositoryInstall = async (directory) => { - calls.push(directory) - await fakePackage(directory) - } - const cache = new RepositoryCache(root, { install }) - const specifier = 'github:owner/repository#0123456789abcdef' - - const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)]) - expect(concurrent).toBe(first) - expect(calls).toHaveLength(1) - - const reopened = new RepositoryCache(root, { install: async () => { throw new Error('cache miss') } }) - expect(await reopened.resolve(specifier)).toBe(first) - expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({ - packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`, - dependencies: { repository: specifier }, - }) - - const second = await cache.resolve('github:owner/repository#fedcba9876543210') - expect(second).not.toBe(first) - expect(calls).toHaveLength(2) - }) - - it('accepts the valid winner when independent cache instances race', async () => { - const root = await temporaryRoot('repository-race') - const bothStarted = Promise.withResolvers<undefined>() - let starts = 0 - const install: RepositoryInstall = async (directory) => { - await fakePackage(directory) - starts += 1 - if (starts === 2) bothStarted.resolve(undefined) - await bothStarted.promise - } - const specifier = 'github:owner/repository#race' - - const [first, second] = await Promise.all([ - new RepositoryCache(root, { install }).resolve(specifier), - new RepositoryCache(root, { install }).resolve(specifier), - ]) - - expect(second).toBe(first) - expect(starts).toBe(2) - expect(await readdir(root)).toHaveLength(1) - }) - - it('removes a failed staging tree and permits an exact retry', async () => { - const root = await temporaryRoot('repository-retry') - let attempts = 0 - const cache = new RepositoryCache(root, { install: async (directory) => { - attempts += 1 - if (attempts === 1) throw new Error('install failed') - await fakePackage(directory) - } }) - - await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository') - expect(await readdir(root)).toEqual([]) - await expect(cache.resolve('github:owner/repository#ref')).resolves.toContain('node_modules') - expect(attempts).toBe(2) - }) - - it('rejects empty or padded specifiers before touching the cache', async () => { - const root = await temporaryRoot('repository-input') - const cache = new RepositoryCache(root, { install: fakePackage }) - expect(() => cache.resolve('')).toThrow('non-empty unpadded string') - expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string') - await expect(readdir(root)).resolves.toEqual([]) - }) - - it('fails loud on a corrupt published marker instead of reinstalling it', async () => { - const root = await temporaryRoot('repository-corrupt') - const specifier = 'github:owner/repository#corrupt' - const key = createHash('sha256').update(specifier).digest('hex') - const entry = join(root, key) - await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true }) - await writeFile(join(entry, '.repository-cache.json'), '{}\n') - const cache = new RepositoryCache(root, { install: async () => { throw new Error('must not reinstall') } }) - - await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid') - }) - - it('isolates and prepares a .dsh-plugin Git subpath from an enclosing pnpm workspace', { timeout: 60_000 }, async () => { - const root = await temporaryRoot('repository-pnpm') - const repository = join(root, 'source') - await mkdir(join(repository, '.dsh-plugin'), { recursive: true }) - await mkdir(join(repository, '.dsh-plugin', 'build-helper'), { recursive: true }) - await mkdir(join(repository, '.dsh-plugin', 'prepare-helper'), { recursive: true }) - await mkdir(join(repository, 'skills', 'fixture'), { recursive: true }) - const shadowPnpm = join(root, 'shadow-pnpm') - await mkdir(shadowPnpm) - await writeFile(join(shadowPnpm, 'pnpm'), '#!/bin/sh\nexit 99\n', { mode: 0o700 }) - await writeFile(join(shadowPnpm, 'pnpm.bat'), '@exit /b 99\r\n') - await writeFile(join(repository, 'package.json'), `${JSON.stringify({ - name: 'repository-fixture', - private: true, - version: '1.0.0', - packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`, - })}\n`) - await writeFile(join(repository, 'pnpm-workspace.yaml'), 'packages: []\n') - await writeFile(join(repository, 'pnpm-lock.yaml'), [ - "lockfileVersion: '9.0'", - 'settings:', - ' autoInstallPeers: true', - ' excludeLinksFromLockfile: false', - 'importers:', - ' .: {}', - '', - ].join('\n')) - await writeFile(join(repository, '.dsh-plugin', 'build-helper', 'package.json'), `${JSON.stringify({ - name: 'repository-build-helper', - version: '1.0.0', - bin: 'index.js', - })}\n`) - await writeFile(join(repository, '.dsh-plugin', 'build-helper', 'index.js'), [ - '#!/usr/bin/env node', - "require('node:fs').writeFileSync('dependency-built.txt', 'dependency available\\n')", - '', - ].join('\n'), { mode: 0o700 }) - await writeFile(join(repository, '.dsh-plugin', 'prepare-helper', 'package.json'), `${JSON.stringify({ - name: 'repository-prepare-helper', - version: '1.0.0', - bin: { 'dsh-plugin-prepare': 'index.js' }, - })}\n`) - await writeFile(join(repository, '.dsh-plugin', 'prepare-helper', 'index.js'), [ - '#!/usr/bin/env node', - "const { cpSync, mkdirSync, writeFileSync } = require('node:fs')", - "mkdirSync('dsh-plugin-assets/skills', { recursive: true })", - "cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })", - "writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')", - "writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}|${process.env.PNPM_CONFIG_IGNORE_WORKSPACE ?? 'absent'}\\n`)", - "writeFileSync('environment.json', `${JSON.stringify({ path: process.env.PATH, pathExt: process.env.PATHEXT })}\\n`)", - '', - ].join('\n'), { mode: 0o700 }) - await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n') - await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({ - name: 'repository-plugin-fixture', - version: '1.0.0', - scripts: { - // The fixture owns dependency installation, not platform-specific - // node_modules/.bin shim generation during pnpm's Git preparation. - prepack: [ - 'node ./node_modules/repository-build-helper/index.js', - 'node ./node_modules/repository-prepare-helper/index.js', - ].join(' && '), - }, - devDependencies: { - 'repository-build-helper': 'file:./build-helper', - 'repository-prepare-helper': 'file:./prepare-helper', - }, - dsh: { skills: ['../skills'] }, - })}\n`) - await execFileAsync('git', ['init', '--quiet'], { cwd: repository }) - await execFileAsync('git', ['add', '.'], { cwd: repository }) - await execFileAsync('git', [ - '-c', 'user.name=Repository Fixture', - '-c', 'user.email=repository@example.invalid', - 'commit', '--quiet', '-m', 'fixture', - ], { cwd: repository }) - const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' }) - const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}&path:/.dsh-plugin` - vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible') - vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden') - vi.stubEnv('PNPM_HOME', shadowPnpm) - vi.stubEnv('PATH', [shadowPnpm, ...(process.env.PATH === undefined ? [] : [process.env.PATH])].join(delimiter)) - vi.stubEnv('PATHEXT', '.BAT;.CMD;.EXE') - - const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier) - await expect(readFile(join(installed, 'dependency-built.txt'), 'utf8')).resolves.toBe('dependency available\n') - await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent|true\n') - const environment = JSON.parse(await readFile(join(installed, 'environment.json'), 'utf8')) as { - path: string - pathExt: string - } - expect(environment.path.split(delimiter)).not.toContain(shadowPnpm) - expect(environment.pathExt.split(';')[0]?.toUpperCase()).toBe('.CMD') - await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply') - expect(lf(await readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))) - .toBe('repository skill source\n') - await expect(readFile(join(installed, 'package.json'), 'utf8')) - .resolves.toContain('repository-plugin-fixture') - }) -}) diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 29786ca332..dd08db1425 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: 2a87b01ad4819750a58163f8c472e61ea633588e -README.zh.md: dc79895355546812aa3371487190724f169c6260 +README.md: 70ecc181da8f0c120b8da0d55f68d47bf22d5820 +README.zh.md: 11f10bf561429c11471ff57d08950677e4924b40 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 2a87b01ad4..70ecc181da 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. +The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, and telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index dc79895355..11f10bf561 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 +以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials 和遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox`、`@deepseek-ai/dsh-tool-pwsh`)。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 400fa766d7..66612f70fd 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -21,13 +21,6 @@ config: root: ['.'] - # The profile's cordis.patch.yml replaces this row's config to select exact GitHub - # repository Plugin generations. The app registers the DSH-owned runtime even - # when the list is empty so a later personal-config edit can load - # transactionally; one-shot headless runs consume the startup value only. - - id: repository-plugins - name: '@deepseek-ai/dsh-repository-plugin' - - id: llm name: '@deepseek-ai/dsh-llm' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 4713dfa216..def597150e 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -63,7 +63,6 @@ "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", - "@deepseek-ai/dsh-repository-plugin": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index 0fe7dd9de3..ece8c7857f 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -36,9 +36,7 @@ const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000 /** * Valid `serverName`: 1–32 chars of `[A-Za-z0-9_-]`. Kept well under the * 64-char public-name budget so typical raw tool names survive unhashed. - * Exported so upstream producers of Config inputs (repository-plugin's - * `.mcp.json` prepare-time validation) reject the same names this registry - * would. + * Exported so config producers can reject the same names this registry would. */ export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ diff --git a/packages/self-modification/README.i18n.yaml b/packages/self-modification/README.i18n.yaml index e6e0fa54c8..5a4ae4b3b4 100644 --- a/packages/self-modification/README.i18n.yaml +++ b/packages/self-modification/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/self-modification/README.md -README.md: c94f9ac9a79709e418448d9e440c38296e759335 -README.zh.md: 44b5d9ff4cb09fb4a4e44f446894c9925a62ec51 +README.md: 2f409f779ae32c9eedc9c57dbb6476c0639205ca +README.zh.md: 09c34c18b66803c544d7a572820c2001405fb6dd diff --git a/packages/self-modification/README.md b/packages/self-modification/README.md index c94f9ac9a7..2f409f779a 100644 --- a/packages/self-modification/README.md +++ b/packages/self-modification/README.md @@ -2,9 +2,8 @@ English | [中文](README.zh.md) -Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again — plus the restricted repository Plugin runtime. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. The group is the landing zone for future self-modification packages. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). | Package | Role | ctx key | |---|---|---| | [`tool-cordis/`](tool-cordis/README.md) | Model-facing runtime inspection and temporary-plugin tools | registers on `ctx.tools` | -| [`repository-plugin/`](repository-plugin/README.md) | Repository skill and MCP composition | registers a Loader builtin | diff --git a/packages/self-modification/README.zh.md b/packages/self-modification/README.zh.md index 44b5d9ff4c..09c34c18b6 100644 --- a/packages/self-modification/README.zh.md +++ b/packages/self-modification/README.zh.md @@ -2,9 +2,8 @@ [English](README.md) | 中文 -agent 修改自身运行时:检查已加载的插件与服务接口、挂载模型编写的插件并再次 dispose,外加受限 repository Plugin 运行时。设计居所:[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +agent 修改自身运行时:检查已加载的插件与服务接口、挂载模型编写的插件并再次 dispose。该组是未来自我修改类包的落点。设计居所:[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 | 包(package) | 角色 | ctx 键 | |---|---|---| | [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect`/`cordis_mount`/`cordis_unmount` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理内存中的临时插件 | 注册到 `ctx.tools` | -| [`repository-plugin/`](repository-plugin/README.md) | 通过 DSH 自有子 Plugin 准备并挂载静态 repository skills 与通用 `.mcp.json` server | 注册一个 Loader builtin | diff --git a/packages/self-modification/repository-plugin/README.i18n.yaml b/packages/self-modification/repository-plugin/README.i18n.yaml deleted file mode 100644 index 6b0e958c2c..0000000000 --- a/packages/self-modification/repository-plugin/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/self-modification/repository-plugin/README.md -README.md: 666f00e02b9ab33bff348df6b4ff90e3f3bfecc7 -README.zh.md: b09f68bc17a4eb08df6ecbb3782e14bf26fb7d7f diff --git a/packages/self-modification/repository-plugin/README.md b/packages/self-modification/repository-plugin/README.md deleted file mode 100644 index 666f00e02b..0000000000 --- a/packages/self-modification/repository-plugin/README.md +++ /dev/null @@ -1,128 +0,0 @@ -# @deepseek-ai/dsh-repository-plugin - -English | [中文](README.zh.md) - -Trusted repository package format for DeepSeek Harness. A `.dsh-plugin` npm package may contribute a compiled Cordis/DSH Plugin entry, skill roots, and a common `.mcp.json`; its ordinary `prepack` lifecycle owns dependency installation and source compilation before the DSH prepare helper validates the outputs and emits the Loader wrapper. Static contributions compose [`dsh-skill-local`](../../skill/skill-local/README.md) and [`dsh-mcp-client`](../../mcp/mcp-client/README.md). Design rationale: [trusted repository package code](../../../.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md) and the [static contribution subformat](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md). - -## Authoring format - -Place an ordinary package in the repository's `.dsh-plugin` directory: - -```json -{ - "name": "humanize-dsh-plugin", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "tsc", - "prepack": "npm run build && dsh-plugin-prepare" - }, - "dsh": { - "entry": "./lib/plugin.js", - "skills": ["../skills"], - "mcpServers": "../.mcp.json" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "1.29.0" - }, - "devDependencies": { - "@deepseek-ai/dsh-repository-plugin": "^0.0.1", - "typescript": "6.0.3" - } -} -``` - -`scripts.prepack` must be non-empty and invoke `dsh-plugin-prepare`; it may run arbitrary package-owned build steps first. The package declares `@deepseek-ai/dsh-repository-plugin` as an ordinary development dependency so its published executable is available to that lifecycle. DSH does not inject the helper: the repository package declares and runs its own compiler, runtime dependencies, preparation helper, and other npm lifecycle code. The selected package is installed from its own manifest instead of inheriting an enclosing pnpm workspace, so declare every dependency it needs and do not depend on workspace-only hoisting. DSH does not transpile TypeScript or infer a package entry. - -`dsh.entry` is an optional relative path to a compiled ESM Cordis Plugin inside `.dsh-plugin`. The module may use either namespace exports or a default export and owns its ordinary `name`, `inject`, `Config`, registrations, and effects. `dsh.skills` is an optional array of local skill roots, and `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one of the three fields is required. Skill and MCP paths may reach adjacent repository assets but must remain beneath the directory containing `.dsh-plugin`; the compiled entry must remain inside the package selected and packed by the package manager. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory. - -The repository package and every dependency or lifecycle script it runs are trusted code, just like an npm package selected directly by the user. This format is not a sandbox: install only repositories whose code may access the host process, filesystem, network, and services declared through Cordis. Exact refs and the immutable cache provide identity and reproducibility, not isolation. - -## Standalone app configuration - -The shipped `dsh-base` bundle every profile starts from contains an empty `repository-plugins` row. A user enables exact GitHub generations by replacing that row's config in a user patch layer — `$DSH_HOME/profiles/<name>/cordis.patch.yml`, or the home-level `$DSH_HOME/cordis.patch.yml` shared by every profile; a `--patch` overlay patches the same row for one run: - -```yaml -- id: repository-plugins - name: '@deepseek-ai/dsh-repository-plugin' - config: - repositories: - - 'github:PolyArch/humanize#<commit>' - - 'github:owner/repository#<ref>&path:/plugins/one/.dsh-plugin' -``` - -Each source must use `github:owner/repository#<ref>`. Omitting `&path:` selects `/.dsh-plugin`; an explicit path is absolute within the repository and must end in `.dsh-plugin`. A commit ref gives the clearest immutable identity, while tags and branches remain accepted exact config values. `cacheDir` may override the default `$DSH_HOME/cache/repository-plugins` cache root. - -Git transport uses the host's ordinary Git authentication. Public repositories need no credentials; private sources require a read-only credential or SSH agent that can read the selected repository. DSH removes credential-shaped environment variables before package lifecycles, so configure Git itself, such as through a credential helper or job-scoped Git config, instead of expecting an exported token variable to cross that boundary. Repository lifecycle code is trusted and can invoke Git, so use the narrowest repository-scoped credential available. - -Long-lived surfaces watch both `cordis.patch.yml` layers through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. One-shot runs read the layers only at startup, and a `--patch` overlay is never watched. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md). - -## Preparation - -During exact Git installation, DSH's bundled pnpm installs the selected package from its own manifest. A transaction-owned `pnpm` wrapper reinvokes the same pinned pnpm with `--ignore-workspace`, so an enclosing workspace lockfile cannot suppress dependencies declared only by the selected `.dsh-plugin` package. The required `prepack` lifecycle runs after that dependency installation and before the selected subdirectory is packed; its ordinary `node_modules/.bin` lookup obtains `dsh-plugin-prepare` from the declared direct development dependency on `@deepseek-ai/dsh-repository-plugin`. That package marks its Cordis/DSH runtime peers optional so using the executable alone does not install the runtime graph. Package-owned commands may build TypeScript or other source before invoking the helper. The helper validates `package.json#dsh`, verifies that the compiled entry is an in-package file, validates skill and MCP sources, copies static assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained both the direct development dependency and a `prepack` declaration containing the helper command. Failure to resolve the published helper, install dependencies, build, or prepare fails before a cache generation is published. Rationale: [npm-backed Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md). - -## Runtime composition - -Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates its prepared static manifest to that builtin, then imports and mounts `dsh.entry` when declared. The wrapper can statically gate only the `loader`, `skills`, and `tools` services implied by the prepared manifest; the entry's own `inject` is discovered when that child is mounted. The entry must reach `ACTIVE`, so a missing entry-only service or startup failure rejects the repository generation instead of committing an inert child, and all effects disappear on Loader removal or rollback. The runtime likewise validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped by `files`/`.npmignore` or damaged in cache fails instead of silently losing contributions. Repository skill roots mount as uniquely named `dsh-skill-local` providers with default project/user roots excluded and watching disabled; cached package generations are immutable. - -## Common MCP format - -The `.mcp.json` root is `{ "mcpServers": { ... } }`. A stdio entry accepts only `type: "stdio"` (optional), `command`, `args`, and `env`; an HTTP entry accepts only `type: "http"`, `url`, and `headers`. String values support exact `${NAME}` process-environment expansion at Plugin load, and a missing name fails that load. HTTP URLs become the existing MCP client's `streamable-http` transport; stdio entries use the prepared package directory as `cwd`. - -Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle. Repository-declared servers enable its strict startup mode: Plugin activation waits for the initial connection and tool synchronization, so the first model request observes a fully registered initial tool generation, while a network, child-process, discovery, or registration failure rejects the candidate repository generation instead of silently activating without its declared tools. - -## Export shape - -Namespace Plugin: named exports `name` / `inject` / `apply`, preparation constants, and `prepareDshPlugin`; no default export. The package also exposes the `dsh-plugin-prepare` executable and an invariant companion. - -## Model Experience - -### Repository skills - -#### What the model sees - -Indirectly through `dsh-tool-skill`: prepared, model-invocable skills join its logged catalog and selected instruction-body surface under their declared names and descriptions. The exact consumer schema is in the generated [`skill` tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill). - -#### Token effect - -Conditional and data-dependent: each visible repository skill adds one capped catalog row; loading one adds its full current instruction body and resource-base guidance to retained tool history. - -#### KV Cache effect - -A stable prepared Plugin set is prefix-stable. Adding, removing, or replacing a repository Plugin can append the consumer's replacement catalog and affect later request prefixes. - -### Repository MCP tools - -#### What the model sees - -Indirectly through `dsh-mcp-client`: every connected server contributes its server-qualified tool schemas, and calls retain that client's canonical MCP results and rendering. - -#### Token effect - -Conditional on successful connection and the remote tool list; schemas recur on requests in the active tool view, while calls and results remain in history until compaction. - -#### KV Cache effect - -Stable connected tool lists are prefix-stable. Plugin lifecycle or MCP tool-list changes can change later tool-schema prefixes from the first affected definition. - -### Repository code - -#### What the model sees - -Data-dependent. The trusted Cordis entry may contribute any DSH behavior available through its declared services and events, including tools, prompt sections, policies, commands, and transformations. Every model-visible contribution remains subject to its owning DSH seam's logging and lifecycle contract. - -#### Token effect - -Defined by the services and registrations the entry contributes; the repository format itself adds no model content. - -#### KV Cache effect - -Stable registrations preserve the owning surface's normal prefix behavior. Loading, removing, or replacing the exact repository generation can change any prefixes affected by that Plugin. - -## Known Limitations and Deferred Work - -- **No code sandbox** — `dsh.entry`, npm dependencies, and package lifecycle scripts execute with the DSH host's authority; repository trust is mandatory. -- **Entry-only service dependencies are not pre-gated** — the generated wrapper cannot declare an entry module's `inject` before importing it. Any service beyond those implied by Skills or MCP must already exist when the wrapper mounts the entry, or that repository generation rejects. -- **No MCP authentication protocol** — static headers may use environment expansion, but OAuth-bearing definitions reject and private-server login flows are not implemented here. -- **Generated assets are immutable runtime input** — repository cache generations are not watched; source, ref, path, or configuration must select another prepared generation. diff --git a/packages/self-modification/repository-plugin/README.zh.md b/packages/self-modification/repository-plugin/README.zh.md deleted file mode 100644 index b09f68bc17..0000000000 --- a/packages/self-modification/repository-plugin/README.zh.md +++ /dev/null @@ -1,128 +0,0 @@ -# @deepseek-ai/dsh-repository-plugin - -[English](README.md) | 中文 - -这是 DeepSeek Harness 的受信任 repository 包格式。`.dsh-plugin` NPM 包可以贡献已编译的 Cordis/DSH 插件入口、skill(技能)根和通用 `.mcp.json`;其常规 `prepack` 生命周期负责安装依赖并编译源码,随后 DSH 准备辅助程序校验输出并生成 Loader 包装层。静态贡献由 [`dsh-skill-local`](../../skill/skill-local/README.md) 与 [`dsh-mcp-client`](../../mcp/mcp-client/README.md) 组合。设计依据见[受信任 repository 包代码](../../../.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md)和[静态贡献子格式](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。 - -## 创作格式 - -在仓库的 `.dsh-plugin` 目录中放置一个普通包: - -```json -{ - "name": "humanize-dsh-plugin", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "tsc", - "prepack": "npm run build && dsh-plugin-prepare" - }, - "dsh": { - "entry": "./lib/plugin.js", - "skills": ["../skills"], - "mcpServers": "../.mcp.json" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "1.29.0" - }, - "devDependencies": { - "@deepseek-ai/dsh-repository-plugin": "^0.0.1", - "typescript": "6.0.3" - } -} -``` - -`scripts.prepack` 必须非空并调用 `dsh-plugin-prepare`;可以先运行任意包自有的构建步骤。包将 `@deepseek-ai/dsh-repository-plugin` 声明为普通开发依赖,使该生命周期可以使用其已发布的可执行文件。DSH 不会注入辅助程序:repository 包自行声明并运行编译器、运行时依赖、准备辅助程序及其他 NPM 生命周期代码。所选包按自身 manifest 独立安装,而不继承外层 pnpm workspace,因此必须声明所需的每项依赖,不能依赖仅由 workspace 提升而可见的包。DSH 不转译 TypeScript,也不推断包入口。 - -`dsh.entry` 是指向 `.dsh-plugin` 内已编译 ESM Cordis 插件的可选相对路径。该模块可以使用 namespace 导出或 default export,并自行拥有常规的 `name`、`inject`、`Config`、注册和 effect。`dsh.skills` 是可选的本地 skill 根数组,`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;三个字段中至少声明一个。skill 和 MCP 路径可以引用相邻的 repository 资源,但必须留在包含 `.dsh-plugin` 的目录下;已编译入口必须留在由包管理器选中并打包的包内。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` 包。 - -repository 包及其运行的每项依赖或生命周期脚本都是受信任代码,与用户直接选择的 NPM 包相同。本格式不是沙箱:只有在你信任仓库代码并愿意允许其访问宿主进程、文件系统、网络及其通过 Cordis 声明的服务时才应安装。精确 ref 和不可变缓存提供身份与可复现性,而非隔离。 - -## 独立应用配置 - -随附的 `dsh-base` 组合包是每个 profile 的起点,其中包含一个空 `repository-plugins` 配置项。用户可在用户 patch 层中替换该配置项的配置来启用精确指定的 GitHub generation:写入 `$DSH_HOME/profiles/<name>/cordis.patch.yml`,或写入各 profile 共享的 home 级 `$DSH_HOME/cordis.patch.yml`;`--patch` overlay 则只为单次运行 patch 同一配置项: - -```yaml -- id: repository-plugins - name: '@deepseek-ai/dsh-repository-plugin' - config: - repositories: - - 'github:PolyArch/humanize#<commit>' - - 'github:owner/repository#<ref>&path:/plugins/one/.dsh-plugin' -``` - -每个源都必须采用 `github:owner/repository#<ref>`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为精确配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。 - -Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有源需要可读取所选仓库的只读凭据或 SSH agent。DSH 会在包生命周期运行前移除名称符合凭据模式的环境变量,因此请配置 Git 本身,例如使用 Git 凭据辅助工具或作业作用域的 Git 配置,而不要指望已导出的 token 变量跨越该边界。仓库生命周期代码受信任且可以调用 Git,因此请使用作用域最窄且仅限所选仓库的凭据。 - -长期运行的 surface 通过 Cordis HMR(热模块替换)监视两个 `cordis.patch.yml` 层。有效的源列表变更会安装并替换整套 repository Plugin generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。一次性运行只在启动时读取这些层,`--patch` overlay 则从不被监视。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入 repository Plugin 的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。 - -## 准备阶段 - -安装精确指定的 Git 源时,DSH 随附的 pnpm 会按所选包自身的 manifest 安装。由事务持有的 `pnpm` 包装脚本会以 `--ignore-workspace` 重新调用同一份锁定的 pnpm,因此外层 workspace lockfile 无法抑制仅由所选 `.dsh-plugin` 包声明的依赖。必需的 `prepack` 生命周期在该依赖安装完成后、选定子目录打包前运行;其常规 `node_modules/.bin` 查找会从直接声明的 `@deepseek-ai/dsh-repository-plugin` 开发依赖中取得 `dsh-plugin-prepare`。该包把 Cordis/DSH 运行时对等依赖(peer dependency)标为可选,因此单独使用该可执行文件不会安装运行时依赖图。包自有命令可以在调用辅助程序前构建 TypeScript 或其他源码。辅助程序会校验 `package.json#dsh`,确认已编译入口是包内文件,校验 skill 与 MCP 源,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装层前,DSH 会重新校验已安装包是否仍同时保留该直接开发依赖,以及包含该辅助命令的 `prepack` 声明。无法解析已发布的辅助程序,或安装依赖、构建或准备失败时,流程会在发布缓存 generation 前失败。设计依据见[基于 NPM 的 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md)。 - -## 运行时组合 - -加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装层都把已准备的静态 manifest(元数据清单)委托给该 builtin,再在声明了 `dsh.entry` 时导入并挂载该入口。包装层只能静态门控已准备 manifest 所隐含的 `loader`、`skills` 与 `tools` 服务;入口自身的 `inject` 要到挂载该子级时才会发现。入口必须进入 `ACTIVE`,因此缺少入口专用服务或启动失败时,会拒绝 repository generation,而不会提交未激活的子级;Loader 移除或回滚时,所有 effect 都会消失。运行时同样会在挂载前校验每个声明的 skill 根都是包内实际存在的目录——生成输出因 `files`/`.npmignore` 被丢弃或在缓存中损坏的包会加载失败,而不是静默丢失贡献。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存包 generation 是不可变的。 - -## 通用 MCP 格式 - -`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"`、`command`、`args` 和 `env`;HTTP 条目只接受 `type: "http"`、`url` 和 `headers`。字符串值在插件加载时支持严格的 `${NAME}` 进程环境变量展开;缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transport;stdio 条目以已准备的包目录作为 `cwd`。 - -未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期。Repository 声明的 server 会启用其严格启动模式:插件激活会等待初始连接与工具同步,因此首个模型请求会看到已完整注册的初始工具 generation;网络、子进程、发现或注册失败则会拒绝候选 repository generation,而不是在缺少已声明工具的情况下静默激活。 - -## 导出形状 - -Namespace 插件:具名导出 `name`/`inject`/`apply`、准备阶段常量和 `prepareDshPlugin`,不提供 default export。本包还提供 `dsh-plugin-prepare` 可执行文件和 invariant companion。 - -## 模型体验 - -### Repository skill - -#### 模型看到什么 - -通过 `dsh-tool-skill` 间接呈现:已准备且允许模型调用的 skill 会按其声明的名称和描述进入该消费方记录到日志的目录及所选指令正文表面。消费方的确切 schema 见生成的 [`skill` 工具目录](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill)。 - -#### Token 影响 - -有条件且随数据变化:每个可见的 repository skill 增加一行受限长度的目录项;加载一个 skill 会把其当前完整指令正文和资源基址指引加入保留的工具历史。 - -#### KV Cache 影响 - -稳定的已准备插件集合保持前缀稳定。添加、移除或替换 repository 插件可能使消费方追加替换目录,并影响后续请求前缀。 - -### Repository MCP 工具 - -#### 模型看到什么 - -通过 `dsh-mcp-client` 间接呈现:每个已连接 server 都贡献带 server 限定名的工具 schema;调用会保留该 client 的规范 MCP 结果和渲染。 - -#### Token 影响 - -取决于连接成功和远端工具列表;schema 会在当前工具视图中的请求上重复出现,而调用与结果会留在历史中直至压缩(compaction)。 - -#### KV Cache 影响 - -稳定的已连接工具列表保持前缀稳定。插件生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。 - -### Repository 代码 - -#### 模型看到什么 - -取决于数据。受信任的 Cordis 入口可以通过其声明的服务和事件贡献任意可用的 DSH 行为,包括工具、提示词片段、策略、命令和转换。每项模型可见贡献仍受所属 DSH seam 的日志与生命周期约定约束。 - -#### Token 影响 - -由入口贡献的服务和注册决定;repository 格式本身不添加模型内容。 - -#### KV Cache 影响 - -稳定的注册会保留所属表面的正常前缀行为。加载、移除或替换精确的 repository generation,可能改变受该插件影响的任意前缀。 - -## 已知限制与暂缓事项 - -- **没有代码沙箱**:`dsh.entry`、NPM 依赖和包生命周期脚本以 DSH 宿主权限执行;必须信任该 repository。 -- **入口专用服务依赖不会预先门控**:生成的包装层无法在导入入口模块前声明其 `inject`。除 skill 或 MCP 隐含的服务外,其他任何服务在包装层挂载入口时都必须已经存在,否则该 repository generation 会被拒绝。 -- **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。 -- **生成资源是不可变运行时输入**:repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。 diff --git a/packages/self-modification/repository-plugin/package.json b/packages/self-modification/repository-plugin/package.json deleted file mode 100644 index f20ec986c2..0000000000 --- a/packages/self-modification/repository-plugin/package.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-repository-plugin", - "description": "Trusted repository package format and Cordis runtime for DeepSeek Harness", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "bin": { - "dsh-plugin-prepare": "./lib/bin.js" - }, - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/bin.js", - "lib/types/**/*.d.ts" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-mcp-client": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", - "@deepseek-ai/dsh-skill-local": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "peerDependenciesMeta": { - "@cordisjs/plugin-loader": { - "optional": true - }, - "@deepseek-ai/dsh-invariants": { - "optional": true - }, - "@deepseek-ai/dsh-mcp-client": { - "optional": true - }, - "@deepseek-ai/dsh-paths": { - "optional": true - }, - "@deepseek-ai/dsh-skill-local": { - "optional": true - }, - "cordis": { - "optional": true - } - }, - "dependencies": { - "zod": "^4.4.3" - }, - "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-mcp-client": "workspace:^", - "@deepseek-ai/dsh-paths": "workspace:^", - "@deepseek-ai/dsh-skill": "workspace:^", - "@deepseek-ai/dsh-skill-local": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/self-modification/repository-plugin/src/bin.ts b/packages/self-modification/repository-plugin/src/bin.ts deleted file mode 100644 index a1787ff090..0000000000 --- a/packages/self-modification/repository-plugin/src/bin.ts +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env node - -/** Command-line entry that prepares the current `.dsh-plugin` package. @module */ - -import { prepareDshPlugin } from './format.ts' - -try { - await prepareDshPlugin() -} catch (error) { - process.stderr.write(`dsh-plugin-prepare: ${error instanceof Error ? error.message : String(error)}\n`) - process.exitCode = 1 -} diff --git a/packages/self-modification/repository-plugin/src/format.ts b/packages/self-modification/repository-plugin/src/format.ts deleted file mode 100644 index 7af948595d..0000000000 --- a/packages/self-modification/repository-plugin/src/format.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * Trusted repository-package preparation and prepared-manifest validation. - * @module - */ - -import { cp, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises' -import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' -import { z } from 'zod' -import { parseMcpDocument } from './mcp.ts' - -/** Fixed module filename loaded from an installed prepared plugin package. */ -export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs' -/** Fixed directory containing copied static plugin assets. */ -export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets' -/** Loader builtin used by every generated repository wrapper. */ -export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin' -/** Dependency-provided command that repository package `prepack` lifecycles must invoke. */ -export const REPOSITORY_PLUGIN_PREPARE_COMMAND = 'dsh-plugin-prepare' -/** Published package whose direct development dependency supplies the prepare command. */ -export const REPOSITORY_PLUGIN_PACKAGE_NAME = '@deepseek-ai/dsh-repository-plugin' - -/** - * Whether a package lifecycle declaration names the preparation dependency's helper. - * @param script - package-authored lifecycle command. - * @returns true when the required helper command is present. - */ -export function hasRepositoryPrepareCommand(script: string): boolean { - return script.includes(REPOSITORY_PLUGIN_PREPARE_COMMAND) -} - -const prepackSchema = z.string().min(1).refine( - hasRepositoryPrepareCommand, - { message: `must invoke ${REPOSITORY_PLUGIN_PREPARE_COMMAND}` }, -) - -const sourceMetadataSchema = z.object({ - skills: z.array(z.string().min(1)).default([]), - mcpServers: z.string().min(1).optional(), - entry: z.string().min(1).optional(), -}).strict().refine(value => value.skills.length > 0 || value.mcpServers !== undefined || value.entry !== undefined, { - message: 'declare at least one skill root, mcpServers file, or compiled entry', -}) -const sourcePackageSchema = z.looseObject({ - name: z.string().min(1), - devDependencies: z.looseObject({ - [REPOSITORY_PLUGIN_PACKAGE_NAME]: z.string().min(1), - }), - scripts: z.looseObject({ - prepack: prepackSchema, - }), - dsh: sourceMetadataSchema, -}) -const preparedManifestSchema = z.object({ - name: z.string().min(1), - skills: z.array(z.string().min(1)), - mcpServers: z.string().min(1).optional(), - entry: z.string().min(1).optional(), -}).strict() -const preparedConfigSchema = z.object({ - // Wrappers pass import.meta.url, which is always file: for an installed - // package; any other scheme would only fail later inside fileURLToPath with - // an uncontextualized TypeError, so reject it at this validation boundary. - baseUrl: z.url({ protocol: /^file$/ }), - manifest: preparedManifestSchema, -}).strict() - -/** Prepared manifest embedded in the generated wrapper. */ -export interface PreparedPluginManifest { - name: string - skills: string[] - mcpServers?: string - entry?: string -} - -/** Untrusted generated-wrapper config accepted by the DSH-owned runtime builtin. */ -export interface PreparedPluginConfig { - baseUrl: string - manifest: PreparedPluginManifest -} - -function formatZodError(label: string, error: z.ZodError): Error { - return new Error(`${label}:\n${z.prettifyError(error)}`) -} - -/** - * Validate the config passed by an installed prepared wrapper. - * @param value - wrapper-provided value crossing the file/module boundary. - * @returns a detached typed config. - */ -export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig { - const result = preparedConfigSchema.safeParse(value) - if (!result.success) throw formatZodError('invalid prepared DSH plugin', result.error) - return { - baseUrl: result.data.baseUrl, - manifest: { - name: result.data.manifest.name, - skills: result.data.manifest.skills, - ...result.data.manifest.mcpServers === undefined ? {} : { mcpServers: result.data.manifest.mcpServers }, - ...result.data.manifest.entry === undefined ? {} : { entry: result.data.manifest.entry }, - }, - } -} - -/** - * Whether `candidate` resolves outside `root` — the containment check shared - * by prepare-time asset copying and runtime prepared-path resolution. - * @param root - directory that must contain the candidate. - * @param candidate - absolute path to test. - * @returns true when the candidate escapes the root. - */ -export function isOutside(root: string, candidate: string): boolean { - const path = relative(root, candidate) - /* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */ - return path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path) -} - -async function sourcePath(pluginDirectory: string, sourceRoot: string, configured: string, kind: 'directory' | 'file'): Promise<string> { - if (isAbsolute(configured)) throw new Error(`DSH plugin asset path must be relative: ${JSON.stringify(configured)}`) - let path: string - try { - path = await realpath(resolve(pluginDirectory, configured)) - } catch (cause) { - throw new Error(`DSH plugin asset does not exist: ${JSON.stringify(configured)}`, { cause }) - } - if (isOutside(sourceRoot, path)) { - throw new Error(`DSH plugin asset escapes its plugin source root: ${JSON.stringify(configured)}`) - } - const info = await stat(path) - if (kind === 'directory' ? !info.isDirectory() : !info.isFile()) { - throw new Error(`DSH plugin asset is not a ${kind}: ${JSON.stringify(configured)}`) - } - return path -} - -function wrapperSource(manifest: PreparedPluginManifest): string { - // The manifest is static, so the wrapper's service dependencies are too: - // declaring them gates the wrapper fiber until the composition provides - // them, which means the runtime's SkillLocal/McpClient children activate - // within the wrapper's own load epoch and their failures (duplicate - // provider names, damaged packages) reject the wrapper's Loader - // transaction instead of leaving a silently PENDING or FAILED child. - const inject = [ - 'loader', - ...manifest.skills.length > 0 ? ['skills'] : [], - ...manifest.mcpServers === undefined ? [] : ['tools'], - ] - const entryHelpers = manifest.entry === undefined ? [] : [ - 'function unwrap(exports) {', - ' const value = exports?.default ?? exports', - ' return value?.__esModule ? (value.default ?? value) : value', - '}', - ] - const entryApply = manifest.entry === undefined ? [] : [ - ' const repositoryPlugin = unwrap(await import(manifest.entry))', - " await mount(ctx, repositoryPlugin, 'repository Plugin entry')", - ] - return [ - '// Generated by dsh-plugin-prepare. Do not edit.', - `const manifest = ${JSON.stringify(manifest)}`, - '// Value mirror: Cordis const enum FiberState.ACTIVE; keep aligned with dsh-repository-plugin source.ts.', - 'const FIBER_ACTIVE = 2', - `export const name = ${JSON.stringify(manifest.name)}`, - `export const inject = ${JSON.stringify(inject)}`, - ...entryHelpers, - 'async function mount(ctx, plugin, label, config) {', - ' const fiber = ctx.plugin(plugin, config)', - ' await fiber', - ' if (fiber.state !== FIBER_ACTIVE) {', - ' const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)', - " throw new Error(`${label} did not activate (waiting for services: ${missing.join(', ') || 'unknown'})`)", - ' }', - '}', - 'export async function apply(ctx) {', - ` const runtime = ctx.loader.builtins[${JSON.stringify(REPOSITORY_PLUGIN_BUILTIN)}]`, - ` if (runtime === undefined) throw new Error(${JSON.stringify(`missing Cordis builtin ${REPOSITORY_PLUGIN_BUILTIN}`)})`, - " await mount(ctx, runtime, 'repository Plugin runtime', { baseUrl: import.meta.url, manifest })", - ...entryApply, - '}', - '', - ].join('\n') -} - -/** - * Validate and package one `.dsh-plugin` directory into copied assets plus a generated wrapper. - * Outputs are staged and committed by rename, but the final publish (remove - * old outputs, rename assets, rename entry) is not one atomic step: a crash - * mid-publish can leave assets without an entry or neither. Rerunning prepare - * repairs the package; partial outputs are never importable as a plugin. - * @param directory - `.dsh-plugin` package directory; defaults to the prepare process cwd. - * @returns the generated prepared manifest. - */ -export async function prepareDshPlugin(directory: string = process.cwd()): Promise<PreparedPluginManifest> { - const pluginDirectory = await realpath(resolve(directory)) - let packageValue: unknown - try { - packageValue = JSON.parse(await readFile(join(pluginDirectory, 'package.json'), 'utf8')) as unknown - } catch (cause) { - throw new Error(`failed to read DSH plugin package metadata in ${pluginDirectory}`, { cause }) - } - const parsed = sourcePackageSchema.safeParse(packageValue) - if (!parsed.success) throw formatZodError('invalid DSH plugin package.json', parsed.error) - - const sourceRoot = await realpath(dirname(pluginDirectory)) - const skillSources: string[] = [] - for (const configured of parsed.data.dsh.skills) { - const source = await sourcePath(pluginDirectory, sourceRoot, configured, 'directory') - if (!isOutside(source, pluginDirectory)) { - throw new Error(`DSH skill root cannot contain the .dsh-plugin package: ${JSON.stringify(configured)}`) - } - skillSources.push(source) - } - let mcpSource: string | undefined - if (parsed.data.dsh.mcpServers !== undefined) { - mcpSource = await sourcePath(pluginDirectory, sourceRoot, parsed.data.dsh.mcpServers, 'file') - parseMcpDocument(await readFile(mcpSource, 'utf8')) - } - let entry: string | undefined - if (parsed.data.dsh.entry !== undefined) { - const entrySource = await sourcePath(pluginDirectory, pluginDirectory, parsed.data.dsh.entry, 'file') - entry = `./${relative(pluginDirectory, entrySource).split(sep).join('/')}` - } - - const manifest: PreparedPluginManifest = { - name: parsed.data.name, - skills: skillSources.map((_, index) => `${PREPARED_ASSET_DIRECTORY}/skills/${index}`), - ...mcpSource === undefined ? {} : { mcpServers: `${PREPARED_ASSET_DIRECTORY}/.mcp.json` }, - ...entry === undefined ? {} : { entry }, - } - const staging = await mkdtemp(join(pluginDirectory, '.dsh-plugin-prepare-')) - try { - const stagedAssets = join(staging, PREPARED_ASSET_DIRECTORY) - await mkdir(join(stagedAssets, 'skills'), { recursive: true }) - await Promise.all(skillSources.map((source, index) => cp(source, join(stagedAssets, 'skills', String(index)), { - recursive: true, - force: false, - errorOnExist: true, - }))) - if (mcpSource !== undefined) await copyFile(mcpSource, join(stagedAssets, '.mcp.json')) - await writeFile(join(staging, PREPARED_ENTRY_FILENAME), wrapperSource(manifest)) - - await rm(join(pluginDirectory, PREPARED_ASSET_DIRECTORY), { recursive: true, force: true }) - await rm(join(pluginDirectory, PREPARED_ENTRY_FILENAME), { force: true }) - await rename(stagedAssets, join(pluginDirectory, PREPARED_ASSET_DIRECTORY)) - await rename(join(staging, PREPARED_ENTRY_FILENAME), join(pluginDirectory, PREPARED_ENTRY_FILENAME)) - } finally { - await rm(staging, { recursive: true, force: true }) - } - return manifest -} diff --git a/packages/self-modification/repository-plugin/src/index.ts b/packages/self-modification/repository-plugin/src/index.ts deleted file mode 100644 index 46a020f40a..0000000000 --- a/packages/self-modification/repository-plugin/src/index.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Trusted repository-package runtime for code, skills, and common MCP definitions. - * @module @deepseek-ai/dsh-repository-plugin - */ - -import { readFile, stat } from 'node:fs/promises' -import { dirname, isAbsolute, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import type { Context } from 'cordis' -import type {} from '@cordisjs/plugin-loader' -import { RepositoryCache } from '@cordisjs/plugin-loader/repository' -import * as SkillLocal from '@deepseek-ai/dsh-skill-local' -import * as McpClient from '@deepseek-ai/dsh-mcp-client' -import { z } from 'zod' -import { - REPOSITORY_PLUGIN_BUILTIN, - isOutside, - parsePreparedPluginConfig, - type PreparedPluginConfig, -} from './format.ts' -import { parseMcpDocument, resolveMcpServers } from './mcp.ts' -import { - loadPreparedRepository, - resolveRepositoryCacheDirectory, - resolveRepositorySpecifier, -} from './source.ts' - -export { - PREPARED_ASSET_DIRECTORY, - PREPARED_ENTRY_FILENAME, - REPOSITORY_PLUGIN_BUILTIN, - REPOSITORY_PLUGIN_PACKAGE_NAME, - REPOSITORY_PLUGIN_PREPARE_COMMAND, - prepareDshPlugin, - type PreparedPluginManifest, -} from './format.ts' - -/** Cordis plugin name used by Loader diagnostics. */ -export const name = 'repository-plugin' -/** Loader service required to register the fixed prepared-wrapper builtin. */ -export const inject = ['loader'] - -/** Repository Plugin runtime and source-list configuration. */ -export interface Config { - /** GitHub repository sources with explicit refs and optional `.dsh-plugin` subpaths. */ - repositories?: string[] - /** Persistent generation cache; defaults to `$DSH_HOME/cache/repository-plugins`. */ - cacheDir?: string -} - -export const Config = z.object({ - repositories: z.array(z.string().min(1)).default([]), - cacheDir: z.string().min(1).optional(), -}).strict().default({ repositories: [] }) - -function preparedPath(baseUrl: string, configured: string): string { - if (isAbsolute(configured)) throw new Error(`prepared DSH plugin path must be relative: ${JSON.stringify(configured)}`) - const directory = dirname(fileURLToPath(baseUrl)) - const path = resolve(directory, configured) - if (isOutside(directory, path)) { - throw new Error(`prepared DSH plugin path escapes its package: ${JSON.stringify(configured)}`) - } - return path -} - -async function preparedDirectory(baseUrl: string, configured: string): Promise<string> { - const path = preparedPath(baseUrl, configured) - // A manifest-declared skill root missing from the installed package (files/ - // .npmignore dropping generated outputs, a damaged cache entry) must fail - // the plugin load: the skill provider treats an absent root as legitimately - // empty, which would silently mount a skill-less plugin. - let info - try { - info = await stat(path) - } catch (cause) { - throw new Error(`prepared DSH plugin skill root is missing from the installed package: ${JSON.stringify(configured)}`, { cause }) - } - if (!info.isDirectory()) { - throw new Error(`prepared DSH plugin skill root is not a directory: ${JSON.stringify(configured)}`) - } - return path -} - -async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise<void> { - const config = parsePreparedPluginConfig(value) - const directory = dirname(fileURLToPath(config.baseUrl)) - const skillDirectories = await Promise.all(config.manifest.skills.map(path => preparedDirectory(config.baseUrl, path))) - const mcpConfigs = config.manifest.mcpServers === undefined - ? [] - : resolveMcpServers( - parseMcpDocument(await readFile(preparedPath(config.baseUrl, config.manifest.mcpServers), 'utf8')), - process.env, - directory, - // Schemastery call signatures collapse the parameter to `never` under - // NodeNext; ResolvedMcpServer matches the Config union by design. - ).map(input => McpClient.Config(input as never)) - - await ctx.effect(async function* () { - if (skillDirectories.length > 0) { - const skills = ctx.plugin(SkillLocal, { - providerName: `repository:${config.manifest.name}`, - includeDefaultRoots: false, - customSkillDirs: skillDirectories, - watch: false, - }) - await skills - yield skills.dispose - } - for (const mcpConfig of mcpConfigs) { - const mcp = ctx.plugin(McpClient, mcpConfig) - await mcp - yield mcp.dispose - } - }, `repository-plugin(${config.manifest.name})`) -} - -const preparedRuntime = { - name: 'repository-plugin-runtime', - apply: applyPrepared, -} - -/** - * Register the DSH-owned runtime as the Loader builtin used by fixed prepared wrappers. - * @param ctx - plugin context carrying the Loader service. - */ -export async function apply(ctx: Context, config: Config = {}): Promise<void> { - if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] !== undefined) { - throw new Error(`Loader builtin ${REPOSITORY_PLUGIN_BUILTIN} is already registered`) - } - const repositories = (config.repositories ?? []).map(resolveRepositorySpecifier) - if (new Set(repositories).size !== repositories.length) { - throw new Error('repository sources must resolve to unique exact specifiers') - } - const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir)) - await ctx.effect(async function* () { - ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime - yield () => { - if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) { - Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN) - } - } - for (const repository of repositories) { - const plugin = await loadPreparedRepository(ctx, cache, repository) - yield plugin.dispose - } - }, 'repository-plugin runtime and sources') -} diff --git a/packages/self-modification/repository-plugin/src/invariant.ts b/packages/self-modification/repository-plugin/src/invariant.ts deleted file mode 100644 index 410e8bf69e..0000000000 --- a/packages/self-modification/repository-plugin/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-repository-plugin`. - * @module @deepseek-ai/dsh-repository-plugin/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-repository-plugin' - -/** Cordis companion plugin name. */ -export const name = 'repository-plugin-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the package owns no service state; Loader fibers and the existing skill - * and MCP owners expose the authoritative lifecycle relationships for its composed children. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/self-modification/repository-plugin/src/mcp.ts b/packages/self-modification/repository-plugin/src/mcp.ts deleted file mode 100644 index d2294fb5c6..0000000000 --- a/packages/self-modification/repository-plugin/src/mcp.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Parser for the common `.mcp.json` file consumed by prepared repository plugins. - * @module - */ - -import { z } from 'zod' - -/** - * Restates dsh-mcp-client's `SERVER_NAME_PATTERN` rather than importing it: - * the prepare bin must stay a zod-only module graph (no tools service, no MCP - * SDK). Exported so `repository-plugin.spec.ts` pins equality with the - * client's exported pattern — prepare-time validation cannot drift from the - * registry that enforces uniqueness. - */ -export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ -const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ -const PLACEHOLDER_PATTERN = /\$\{([^}]*)\}/g - -const stringMap = z.record(z.string(), z.string()) -const stdioServerSchema = z.object({ - type: z.literal('stdio').optional(), - command: z.string().min(1), - args: z.array(z.string()).optional(), - env: stringMap.optional(), -}).strict() -const httpServerSchema = z.object({ - type: z.literal('http'), - url: z.string().min(1), - headers: stringMap.optional(), -}).strict() -const documentSchema = z.object({ - mcpServers: z.record(z.string(), z.union([stdioServerSchema, httpServerSchema])), -}).strict() - -/** One supported server entry from the common `.mcp.json` format. */ -export type McpServerDefinition = z.infer<typeof stdioServerSchema> | z.infer<typeof httpServerSchema> - -/** Parsed common MCP document before process-environment expansion. */ -export interface McpDocument { - mcpServers: Record<string, McpServerDefinition> -} - -/** Resolved input handed to the existing `dsh-mcp-client` Config schema. */ -export type ResolvedMcpServer = - | { - transport: 'stdio' - serverName: string - command: string - args: string[] - env: Record<string, string> - cwd: string - failOnStartupError: true - } - | { - transport: 'streamable-http' - serverName: string - url: string - headers: Record<string, string> - failOnStartupError: true - } - -function assertTemplate(value: string, location: string): void { - for (const match of value.matchAll(PLACEHOLDER_PATTERN)) { - const name = match[1] as string - if (!ENVIRONMENT_NAME_PATTERN.test(name)) { - throw new Error(`${location} contains an unsupported environment placeholder ${JSON.stringify(match[0])}`) - } - } - if (value.replace(PLACEHOLDER_PATTERN, '').includes('${')) { - throw new Error(`${location} contains an unterminated environment placeholder`) - } -} - -function visitStrings(serverName: string, definition: McpServerDefinition, visit: (value: string, location: string) => void): void { - if ('command' in definition) { - visit(definition.command, `mcpServers.${serverName}.command`) - definition.args?.forEach((value, index) => { visit(value, `mcpServers.${serverName}.args[${index}]`) }) - Object.entries(definition.env ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.env.${name}`) }) - return - } - visit(definition.url, `mcpServers.${serverName}.url`) - Object.entries(definition.headers ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.headers.${name}`) }) -} - -/** - * Parse and validate one common `.mcp.json` document without resolving environment values. - * @param content - UTF-8 JSON document. - * @returns the supported stdio and Streamable HTTP server definitions. - */ -export function parseMcpDocument(content: string): McpDocument { - let value: unknown - try { - value = JSON.parse(content) as unknown - } catch (cause) { - throw new Error('invalid .mcp.json: expected JSON', { cause }) - } - const result = documentSchema.safeParse(value) - if (!result.success) throw new Error(`invalid .mcp.json:\n${z.prettifyError(result.error)}`) - for (const [serverName, definition] of Object.entries(result.data.mcpServers)) { - if (!SERVER_NAME_PATTERN.test(serverName)) { - throw new Error(`invalid .mcp.json: server name ${JSON.stringify(serverName)} must match ${SERVER_NAME_PATTERN.source}`) - } - visitStrings(serverName, definition, assertTemplate) - } - return result.data -} - -function expand(value: string, environment: NodeJS.ProcessEnv, location: string): string { - return value.replace(PLACEHOLDER_PATTERN, (_placeholder, name: string) => { - const replacement = environment[name] - if (replacement === undefined) throw new Error(`${location} requires missing environment variable ${name}`) - return replacement - }) -} - -function expandMap(values: Record<string, string> | undefined, environment: NodeJS.ProcessEnv, location: string): Record<string, string> { - return Object.fromEntries(Object.entries(values ?? {}).map(([name, value]) => [ - name, - expand(value, environment, `${location}.${name}`), - ])) -} - -/** - * Resolve supported MCP definitions to inputs for the existing MCP client. - * @param document - validated common MCP document. - * @param environment - process environment used for exact `${NAME}` expansion. - * @param cwd - prepared plugin directory used for stdio child processes. - * @returns one existing-client config input per declared server. - */ -export function resolveMcpServers(document: McpDocument, environment: NodeJS.ProcessEnv, cwd: string): ResolvedMcpServer[] { - return Object.entries(document.mcpServers).map(([serverName, definition]) => { - if ('command' in definition) { - return { - transport: 'stdio', - serverName, - command: expand(definition.command, environment, `mcpServers.${serverName}.command`), - args: (definition.args ?? []).map((value, index) => expand(value, environment, `mcpServers.${serverName}.args[${index}]`)), - env: expandMap(definition.env, environment, `mcpServers.${serverName}.env`), - cwd, - failOnStartupError: true, - } - } - const url = expand(definition.url, environment, `mcpServers.${serverName}.url`) - const protocol = new URL(url).protocol - if (protocol !== 'http:' && protocol !== 'https:') { - throw new Error(`mcpServers.${serverName}.url must use http or https`) - } - return { - transport: 'streamable-http', - serverName, - url, - headers: expandMap(definition.headers, environment, `mcpServers.${serverName}.headers`), - failOnStartupError: true, - } - }) -} diff --git a/packages/self-modification/repository-plugin/src/source.ts b/packages/self-modification/repository-plugin/src/source.ts deleted file mode 100644 index 536025d6d7..0000000000 --- a/packages/self-modification/repository-plugin/src/source.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * GitHub repository source validation and prepared-wrapper loading. - * @module - */ - -import { readFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import type { Context, Fiber, FiberState, Plugin } from 'cordis' -import type { RepositoryCache } from '@cordisjs/plugin-loader/repository' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' -import { z } from 'zod' -import { - PREPARED_ENTRY_FILENAME, - REPOSITORY_PLUGIN_PACKAGE_NAME, - REPOSITORY_PLUGIN_PREPARE_COMMAND, - hasRepositoryPrepareCommand, -} from './format.ts' - -// Value mirror: Cordis's const enum has no runtime object to import. Keep -// aligned with `packages/self-modification/tool-cordis/src/fiber-state.ts`. -const FIBER_ACTIVE = 2 as FiberState.ACTIVE - -/** Directory under the Harness home containing immutable repository generations. */ -export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins' - -// The ref segment excludes `#` so `github:o/r#a#b` fails here — at the config -// parser, with the syntax the error message promises — instead of inside the -// cache's pnpm install ('misconfiguration fails loud at the earliest -// resolvable point'). -const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s#&]+)(?:&path:(\/[^\s&]+))?$/ -const installedPackageSchema = z.looseObject({ - devDependencies: z.looseObject({ - [REPOSITORY_PLUGIN_PACKAGE_NAME]: z.string().min(1), - }), - scripts: z.looseObject({ - prepack: z.string().min(1).refine( - hasRepositoryPrepareCommand, - { message: `must invoke ${REPOSITORY_PLUGIN_PREPARE_COMMAND}` }, - ), - }), -}) - -function validPluginPath(path: string): boolean { - const segments = path.split('/').slice(1) - return segments.length > 0 - && segments.at(-1) === '.dsh-plugin' - && segments.every(segment => segment.length > 0 && segment !== '.' && segment !== '..') -} - -/** - * Normalize one user-facing GitHub source to the exact pnpm dependency specifier. - * @param configured - `github:owner/repo#ref` with an optional `&path:/.../.dsh-plugin`. - * @returns the exact specifier, with the root `.dsh-plugin` subpath added when omitted. - * @throws when the GitHub owner, repository, explicit ref, or plugin subpath is invalid. - */ -export function resolveRepositorySpecifier(configured: string): string { - const match = GITHUB_SOURCE_PATTERN.exec(configured) - if (match === null) { - throw new Error(`repository source must use github:owner/repo#<ref> with an optional &path:/.../.dsh-plugin: ${JSON.stringify(configured)}`) - } - const path = match[4] - if (path !== undefined && !validPluginPath(path)) { - throw new Error(`repository source path must be an absolute repository subpath ending in .dsh-plugin without empty, . or .. segments: ${JSON.stringify(path)}`) - } - return path === undefined ? `${configured}&path:/.dsh-plugin` : configured -} - -/** - * Resolve the persistent repository cache root. - * @param configured - explicit cache directory, or undefined for `$DSH_HOME/cache/repository-plugins`. - * @returns an absolute cache directory. - */ -export function resolveRepositoryCacheDirectory(configured: string | undefined): string { - return resolve(configured ?? join(resolveDshHome(), 'cache', DEFAULT_REPOSITORY_CACHE_DIRECTORY)) -} - -async function assertInstalledPackageMetadata(directory: string): Promise<void> { - let value: unknown - try { - value = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as unknown - } catch (cause) { - throw new Error(`failed to read installed DSH plugin package metadata in ${directory}`, { cause }) - } - const result = installedPackageSchema.safeParse(value) - if (!result.success) { - throw new Error([ - `installed DSH plugin package must declare a non-empty scripts.prepack that invokes ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}, and declare ${JSON.stringify(REPOSITORY_PLUGIN_PACKAGE_NAME)} in devDependencies:`, - z.prettifyError(result.error), - 'Clear the matching repository cache generation before retrying the same source, or select a different exact source/ref/path after fixing the package.', - ].join('\n')) - } -} - -/** - * Load one exact repository generation's generated wrapper as a child Cordis fiber. - * @param ctx - repository runtime context that owns the child. - * @param cache - package-manager-native immutable repository cache. - * @param specifier - normalized exact pnpm dependency specifier. - * @returns the settled prepared-wrapper fiber. - * @throws when installation, wrapper import, manifest validation, or child registration fails. - */ -export async function loadPreparedRepository( - ctx: Context, - cache: Pick<RepositoryCache, 'resolve'>, - specifier: string, -): Promise<Fiber> { - const directory = await cache.resolve(specifier) - const filename = join(directory, PREPARED_ENTRY_FILENAME) - try { - await assertInstalledPackageMetadata(directory) - const plugin = await import(/* @vite-ignore */pathToFileURL(filename).href) as Plugin - const fiber = ctx.plugin(plugin) - await fiber - // Awaiting a service-gated fiber returns while it is still PENDING (the - // generated wrapper injects `skills`/`tools` per its manifest). This - // runtime commits the repository configuration transactionally, so a - // composition that never provides a required service must reject the - // transaction here — not settle ACTIVE with a silently pending child. - if (fiber.state !== FIBER_ACTIVE) { - const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) - /* v8 ignore next 2 -- the 'unknown' arm needs a service to appear after the state read; not deterministically stageable. */ - const detail = missing.join(', ') || 'unknown' - throw new Error(`prepared wrapper did not activate (waiting for services: ${detail})`) - } - return await fiber - } catch (cause) { - throw new Error(`failed to load prepared repository Plugin ${JSON.stringify(specifier)} from ${filename}`, { cause }) - } -} diff --git a/packages/self-modification/repository-plugin/tests/mcp-format.spec.ts b/packages/self-modification/repository-plugin/tests/mcp-format.spec.ts deleted file mode 100644 index 709c1a2c4b..0000000000 --- a/packages/self-modification/repository-plugin/tests/mcp-format.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { SERVER_NAME_PATTERN as CLIENT_SERVER_NAME_PATTERN } from '@deepseek-ai/dsh-mcp-client' -import { SERVER_NAME_PATTERN, parseMcpDocument, resolveMcpServers } from '../src/mcp.ts' - -describe('repository plugin common .mcp.json support', () => { - it('validates server names with exactly the pattern the MCP client registry enforces', () => { - // mcp.ts restates the pattern to keep the prepare bin's module graph - // zod-only; this pin is the drift guard. - expect(SERVER_NAME_PATTERN.source).toBe(CLIENT_SERVER_NAME_PATTERN.source) - expect(SERVER_NAME_PATTERN.flags).toBe(CLIENT_SERVER_NAME_PATTERN.flags) - }) - - it('maps Expo-style HTTP servers to the existing Streamable HTTP client config', () => { - const document = parseMcpDocument(JSON.stringify({ - mcpServers: { - expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' }, - }, - })) - - expect(resolveMcpServers(document, {}, '/plugin')).toEqual([{ - transport: 'streamable-http', - serverName: 'expo', - url: 'https://mcp.expo.dev/mcp', - headers: {}, - failOnStartupError: true, - }]) - }) - - it('maps DataJunction-style stdio servers and expands exact environment placeholders', () => { - const document = parseMcpDocument(JSON.stringify({ - mcpServers: { - datajunction: { - command: 'dj-mcp', - args: ['--endpoint', '${DJ_API_URL}'], - env: { DJ_API_URL: '${DJ_API_URL}' }, - }, - }, - })) - - expect(resolveMcpServers(document, { DJ_API_URL: 'http://localhost:8000' }, '/plugin')).toEqual([{ - transport: 'stdio', - serverName: 'datajunction', - command: 'dj-mcp', - args: ['--endpoint', 'http://localhost:8000'], - env: { DJ_API_URL: 'http://localhost:8000' }, - cwd: '/plugin', - failOnStartupError: true, - }]) - }) - - it('fails loud when a declared environment value is absent', () => { - const document = parseMcpDocument(JSON.stringify({ - mcpServers: { datajunction: { command: 'dj-mcp', env: { DJ_API_URL: '${DJ_API_URL}' } } }, - })) - - expect(() => resolveMcpServers(document, {}, '/plugin')).toThrow('missing environment variable DJ_API_URL') - }) - - it('accepts explicit stdio defaults and expands HTTP URLs and headers', () => { - const document = parseMcpDocument(JSON.stringify({ - mcpServers: { - local: { type: 'stdio', command: 'local-mcp' }, - remote: { - type: 'http', - url: 'http://${MCP_HOST}/mcp', - headers: { Authorization: 'Bearer ${MCP_TOKEN}' }, - }, - }, - })) - - expect(resolveMcpServers(document, { MCP_HOST: 'localhost:3000', MCP_TOKEN: 'test-token' }, '/plugin')).toEqual([ - { - transport: 'stdio', - serverName: 'local', - command: 'local-mcp', - args: [], - env: {}, - cwd: '/plugin', - failOnStartupError: true, - }, - { - transport: 'streamable-http', - serverName: 'remote', - url: 'http://localhost:3000/mcp', - headers: { Authorization: 'Bearer test-token' }, - failOnStartupError: true, - }, - ]) - }) - - it('rejects malformed JSON, server names, placeholders, and non-HTTP URLs', () => { - expect(() => parseMcpDocument('{')).toThrow('expected JSON') - expect(() => parseMcpDocument(JSON.stringify({ - mcpServers: { 'bad name': { command: 'server' } }, - }))).toThrow('server name') - expect(() => parseMcpDocument(JSON.stringify({ - mcpServers: { bad: { command: '${BAD-NAME}' } }, - }))).toThrow('unsupported environment placeholder') - expect(() => parseMcpDocument(JSON.stringify({ - mcpServers: { bad: { command: '${UNFINISHED' } }, - }))).toThrow('unterminated environment placeholder') - const ftp = parseMcpDocument(JSON.stringify({ - mcpServers: { remote: { type: 'http', url: 'ftp://example.test/mcp' } }, - })) - expect(() => resolveMcpServers(ftp, {}, '/plugin')).toThrow('must use http or https') - }) - - it('rejects Work IQ OAuth fields instead of treating them as unauthenticated HTTP', () => { - expect(() => parseMcpDocument(JSON.stringify({ - mcpServers: { - workiq: { - type: 'http', - url: 'https://workiq.microsoft.com/mcp', - oauthClientId: 'client-id', - oauthPublicClient: true, - auth: { redirectPort: 3317 }, - }, - }, - }))).toThrow('invalid .mcp.json') - }) -}) diff --git a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts deleted file mode 100644 index 086616f2f9..0000000000 --- a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts +++ /dev/null @@ -1,676 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, relative, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import { RepositoryCache } from '@cordisjs/plugin-loader/repository' -import SkillService from '@deepseek-ai/dsh-skill' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import InvariantService from '@deepseek-ai/dsh-invariants' -import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin' -import * as RepositoryPluginInvariant from '@deepseek-ai/dsh-repository-plugin/invariant' -import { parsePreparedPluginConfig } from '../src/format.ts' -import { - loadPreparedRepository, - resolveRepositoryCacheDirectory, - resolveRepositorySpecifier, -} from '../src/source.ts' - -const roots: string[] = [] - -async function temporaryDirectory(name: string): Promise<string> { - const directory = await mkdtemp(join(tmpdir(), `dsh-repository-plugin-${name}-`)) - roots.push(directory) - return directory -} - -async function writePlugin( - root: string, - name: string, - dsh: Record<string, unknown>, - prepack = RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND, - devDependencies: Record<string, string> = { - [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1', - }, -): Promise<string> { - const directory = join(root, '.dsh-plugin') - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'package.json'), `${JSON.stringify({ - name, - version: '0.0.0', - devDependencies, - scripts: { prepack }, - dsh, - }, undefined, 2)}\n`) - return directory -} - -async function writeSkill(root: string, name: string): Promise<void> { - const directory = join(root, name) - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: Repository fixture skill.\n---\n\nStatic instructions.\n`) -} - -afterEach(async () => { - vi.restoreAllMocks() - vi.unstubAllEnvs() - await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) -}) - -describe('dsh-plugin-prepare', () => { - it('copies declared static assets and emits the fixed import-free wrapper', async () => { - const root = await temporaryDirectory('prepare') - await writeSkill(join(root, 'skills'), 'repository-fixture') - await writeFile(join(root, '.mcp.json'), JSON.stringify({ - mcpServers: { - expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' }, - }, - })) - const directory = await writePlugin(root, 'fixture-plugin', { - skills: ['../skills'], - mcpServers: '../.mcp.json', - }) - - await expect(RepositoryPlugin.prepareDshPlugin(directory)).resolves.toEqual({ - name: 'fixture-plugin', - skills: ['dsh-plugin-assets/skills/0'], - mcpServers: 'dsh-plugin-assets/.mcp.json', - }) - const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8') - expect(wrapper).toContain(`ctx.loader.builtins["${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}"]`) - // Import-free means no static AND no dynamic imports; `import.meta.url` - // (no whitespace, no call parenthesis) is the one allowed appearance. - expect(wrapper).not.toMatch(/\b(?:import|from)\s|\bimport\s*\(/) - await expect(readFile(join(directory, 'dsh-plugin-assets/skills/0/repository-fixture/SKILL.md'), 'utf8')) - .resolves.toContain('Static instructions.') - await expect(readFile(join(directory, 'dsh-plugin-assets/.mcp.json'), 'utf8')) - .resolves.toContain('mcp.expo.dev') - }) - - it('preserves a compiled package entry and accepts a build before the package prepare command', async () => { - const root = await temporaryDirectory('compiled-entry') - const directory = await writePlugin(root, 'compiled-entry-fixture', { - entry: './lib/plugin.mjs', - }, 'npm run build && dsh-plugin-prepare') - await mkdir(join(directory, 'lib')) - await writeFile(join(directory, 'lib/plugin.mjs'), 'export default { name: "compiled-entry" }\n') - - await expect(RepositoryPlugin.prepareDshPlugin(directory)).resolves.toEqual({ - name: 'compiled-entry-fixture', - skills: [], - entry: './lib/plugin.mjs', - }) - const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8') - expect(wrapper).toContain('await import(manifest.entry)') - expect(wrapper).toContain('"entry":"./lib/plugin.mjs"') - }) - - it('rejects unsupported OAuth MCP metadata before publishing outputs', async () => { - const root = await temporaryDirectory('oauth') - await writeFile(join(root, '.mcp.json'), JSON.stringify({ - mcpServers: { - workiq: { - type: 'http', - url: 'https://workiq.microsoft.com/mcp', - oauthClientId: 'client-id', - oauthPublicClient: true, - auth: { redirectPort: 3317 }, - }, - }, - })) - const directory = await writePlugin(root, 'unsupported-oauth', { mcpServers: '../.mcp.json' }) - - await expect(RepositoryPlugin.prepareDshPlugin(directory)).rejects.toThrow('invalid .mcp.json') - await expect(readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - }) - - it('rejects invalid metadata, missing assets, wrong asset types, and escaped paths', async () => { - const malformedRoot = await temporaryDirectory('malformed-package') - const malformed = join(malformedRoot, '.dsh-plugin') - await mkdir(malformed) - await writeFile(join(malformed, 'package.json'), '{') - await expect(RepositoryPlugin.prepareDshPlugin(malformed)).rejects.toThrow('failed to read DSH plugin package metadata') - - const lifecycleRoot = await temporaryDirectory('wrong-lifecycle') - const lifecycle = join(lifecycleRoot, '.dsh-plugin') - await mkdir(lifecycle) - await writeFile(join(lifecycle, 'package.json'), JSON.stringify({ - name: 'wrong-lifecycle', - scripts: { prepare: 'dsh-plugin-prepare' }, - dsh: { skills: ['../skills'] }, - })) - await expect(RepositoryPlugin.prepareDshPlugin(lifecycle)).rejects.toThrow('prepack') - - const skippedPrepareRoot = await temporaryDirectory('skipped-prepare') - const skippedPrepare = await writePlugin( - skippedPrepareRoot, - 'skipped-prepare', - { skills: ['../skills'] }, - 'npm run build', - ) - await expect(RepositoryPlugin.prepareDshPlugin(skippedPrepare)).rejects.toThrow('must invoke dsh-plugin-prepare') - - const undeclaredPrepareRoot = await temporaryDirectory('undeclared-prepare-dependency') - const undeclaredPrepare = await writePlugin( - undeclaredPrepareRoot, - 'undeclared-prepare-dependency', - { skills: ['../skills'] }, - RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND, - {}, - ) - await expect(RepositoryPlugin.prepareDshPlugin(undeclaredPrepare)) - .rejects.toThrow(RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME) - - const emptyRoot = await temporaryDirectory('empty-metadata') - const empty = await writePlugin(emptyRoot, 'empty', {}) - await expect(RepositoryPlugin.prepareDshPlugin(empty)).rejects.toThrow('declare at least one skill root, mcpServers file, or compiled entry') - - const missingRoot = await temporaryDirectory('missing-asset') - const missing = await writePlugin(missingRoot, 'missing', { skills: ['../missing'] }) - await expect(RepositoryPlugin.prepareDshPlugin(missing)).rejects.toThrow('asset does not exist') - - const absoluteRoot = await temporaryDirectory('absolute-asset') - const absolute = await writePlugin(absoluteRoot, 'absolute', { skills: [absoluteRoot] }) - await expect(RepositoryPlugin.prepareDshPlugin(absolute)).rejects.toThrow('asset path must be relative') - - const wrongTypeRoot = await temporaryDirectory('wrong-type') - await writeFile(join(wrongTypeRoot, 'not-a-directory'), 'text') - const wrongType = await writePlugin(wrongTypeRoot, 'wrong-type', { skills: ['../not-a-directory'] }) - await expect(RepositoryPlugin.prepareDshPlugin(wrongType)).rejects.toThrow('asset is not a directory') - - const wrongMcpRoot = await temporaryDirectory('wrong-mcp-type') - await mkdir(join(wrongMcpRoot, 'not-a-file')) - const wrongMcp = await writePlugin(wrongMcpRoot, 'wrong-mcp', { mcpServers: '../not-a-file' }) - await expect(RepositoryPlugin.prepareDshPlugin(wrongMcp)).rejects.toThrow('asset is not a file') - - const containingRoot = await temporaryDirectory('containing-root') - const containing = await writePlugin(containingRoot, 'containing', { skills: ['..'] }) - await expect(RepositoryPlugin.prepareDshPlugin(containing)).rejects.toThrow('cannot contain the .dsh-plugin package') - - const escapedRoot = await temporaryDirectory('escaped-root') - const outside = await temporaryDirectory('outside-root') - await writeSkill(outside, 'outside-skill') - const escaped = await writePlugin(escapedRoot, 'escaped', { skills: [relative(join(escapedRoot, '.dsh-plugin'), outside)] }) - await expect(RepositoryPlugin.prepareDshPlugin(escaped)).rejects.toThrow('escapes its plugin source root') - - const escapedEntryRoot = await temporaryDirectory('escaped-entry') - await writeFile(join(escapedEntryRoot, 'outside.mjs'), 'export default {}\n') - const escapedEntry = await writePlugin(escapedEntryRoot, 'escaped-entry', { entry: '../outside.mjs' }) - await expect(RepositoryPlugin.prepareDshPlugin(escapedEntry)).rejects.toThrow('escapes its plugin source root') - }) - - it('validates prepared wrapper configs with optional MCP assets and code entries', () => { - expect(() => parsePreparedPluginConfig({})).toThrow('invalid prepared DSH plugin') - expect(parsePreparedPluginConfig({ - baseUrl: 'file:///plugin/dsh-plugin.mjs', - manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json', entry: './lib/plugin.js' }, - })).toEqual({ - baseUrl: 'file:///plugin/dsh-plugin.mjs', - manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json', entry: './lib/plugin.js' }, - }) - }) -}) - -describe('prepared repository plugin Loader composition', () => { - it('mounts and removes copied skills through the real Loader and skill-local provider', async () => { - const root = await temporaryDirectory('loader') - await writeSkill(join(root, 'skills'), 'loaded-from-repository') - const directory = await writePlugin(root, 'loader-fixture', { skills: ['../skills'] }) - await RepositoryPlugin.prepareDshPlugin(directory) - - const ctx = new Context() - ctx.baseUrl = pathToFileURL(directory).href + '/' - await ctx.plugin(Loader) - await ctx.plugin(SkillService) - const registrar = ctx.plugin(RepositoryPlugin) - await registrar - expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined() - - const id = await ctx.loader.create({ - name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, - }) - await ctx.loader.await() - await expect(ctx.skills.get('loaded-from-repository')).resolves.toMatchObject({ - name: 'loaded-from-repository', - provider: 'repository:loader-fixture', - content: 'Static instructions.', - }) - - await ctx.loader.remove(id) - await expect(ctx.skills.get('loaded-from-repository')).resolves.toBeUndefined() - await registrar.dispose() - expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined() - await ctx.fiber.dispose() - }) - - it('mounts and removes the repository package code entry through the real Loader', async () => { - const root = await temporaryDirectory('code-loader') - const directory = await writePlugin(root, 'code-loader-fixture', { entry: './lib/plugin.mjs' }) - await mkdir(join(directory, 'lib')) - await writeFile(join(directory, 'lib/plugin.mjs'), [ - "export const name = 'repository-code-proof'", - 'export function apply(ctx) {', - " ctx.provide('repositoryCodeProof', { source: 'compiled-entry' })", - '}', - '', - ].join('\n')) - await RepositoryPlugin.prepareDshPlugin(directory) - - const ctx = new Context() - ctx.baseUrl = pathToFileURL(directory).href + '/' - await ctx.plugin(Loader) - await ctx.plugin(RepositoryPlugin) - const id = await ctx.loader.create({ - name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, - }) - await ctx.loader.await() - const getService = (name: string): unknown => (ctx as unknown as { get(name: string): unknown }).get(name) - expect(getService('repositoryCodeProof')).toEqual({ source: 'compiled-entry' }) - - await ctx.loader.remove(id) - expect(getService('repositoryCodeProof')).toBeUndefined() - await ctx.fiber.dispose() - }) - - it('mounts and removes tools discovered from a repository MCP server', async () => { - const root = await temporaryDirectory('mcp-loader-success') - const server = join(root, 'mcp-server.mjs') - await writeFile(server, [ - "import { createInterface } from 'node:readline'", - 'const lines = createInterface({ input: process.stdin })', - 'for await (const line of lines) {', - ' const request = JSON.parse(line)', - " if (!('id' in request)) continue", - ' let result', - " if (request.method === 'initialize') {", - ' result = {', - ' protocolVersion: request.params.protocolVersion,', - ' capabilities: { tools: {} },', - " serverInfo: { name: 'repository-fixture', version: '0.0.0' },", - ' }', - " } else if (request.method === 'tools/list') {", - ' result = {', - ' tools: [{', - " name: 'proof',", - " description: 'Repository MCP proof.',", - " inputSchema: { type: 'object', properties: {} },", - ' }],', - ' }', - ' } else {', - ' result = {}', - ' }', - " process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\\n`)", - '}', - '', - ].join('\n')) - await writeFile(join(root, '.mcp.json'), JSON.stringify({ - mcpServers: { online: { command: process.execPath, args: [server] } }, - })) - const directory = await writePlugin(root, 'mcp-loader-success-fixture', { mcpServers: '../.mcp.json' }) - await RepositoryPlugin.prepareDshPlugin(directory) - - const ctx = new Context() - ctx.baseUrl = pathToFileURL(directory).href + '/' - await ctx.plugin(Loader) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(RepositoryPlugin) - const id = await ctx.loader.create({ - name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, - }) - await ctx.loader.await() - expect(ctx.tools.get('mcp__online__proof')).toBeDefined() - - await ctx.loader.remove(id) - expect(ctx.tools.get('mcp__online__proof')).toBeUndefined() - await ctx.fiber.dispose() - }) - - it('fails an MCP repository plugin load when its declared server cannot connect', async () => { - const root = await temporaryDirectory('mcp-loader') - await writeFile(join(root, '.mcp.json'), JSON.stringify({ - mcpServers: { offline: { command: join(root, 'missing-mcp-command') } }, - })) - const directory = await writePlugin(root, 'mcp-loader-fixture', { mcpServers: '../.mcp.json' }) - await RepositoryPlugin.prepareDshPlugin(directory) - - const ctx = new Context() - ctx.baseUrl = pathToFileURL(directory).href + '/' - await ctx.plugin(Loader) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(RepositoryPlugin) - await expect(ctx.loader.create({ - name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, - })).rejects.toThrow('initial connection or tool synchronization failed') - expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false) - await ctx.fiber.dispose() - }) - - it('rejects hostile prepared paths before mounting children', async () => { - const root = await temporaryDirectory('prepared-paths') - const ctx = new Context() - ctx.baseUrl = pathToFileURL(root).href + '/' - await ctx.plugin(Loader) - await ctx.plugin(RepositoryPlugin) - - for (const [filename, skillPath] of [ - ['absolute.mjs', resolve(root)], - ['escaped.mjs', '../outside'], - ] as const) { - const wrapper = join(root, filename) - await writeFile(wrapper, [ - "export const inject = ['loader']", - 'export async function apply(ctx) {', - ` await ctx.plugin(ctx.loader.builtins['${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}'], {`, - ` baseUrl: import.meta.url, manifest: { name: 'hostile', skills: [${JSON.stringify(skillPath)}] },`, - ' })', - '}', - '', - ].join('\n')) - await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow('prepared DSH plugin path') - } - await ctx.fiber.dispose() - }) - - it('fails the plugin load when a declared skill root is missing or not a directory', async () => { - const root = await temporaryDirectory('missing-skill-root') - await writeFile(join(root, 'not-a-directory'), 'text') - const ctx = new Context() - ctx.baseUrl = pathToFileURL(root).href + '/' - await ctx.plugin(Loader) - await ctx.plugin(SkillService) - await ctx.plugin(RepositoryPlugin) - - for (const [filename, skillPath, message] of [ - ['missing.mjs', 'dsh-plugin-assets/skills/0', 'skill root is missing from the installed package'], - ['file.mjs', 'not-a-directory', 'skill root is not a directory'], - ] as const) { - const wrapper = join(root, filename) - await writeFile(wrapper, [ - "export const inject = ['loader']", - 'export async function apply(ctx) {', - ` await ctx.plugin(ctx.loader.builtins['${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}'], {`, - ` baseUrl: import.meta.url, manifest: { name: 'damaged', skills: [${JSON.stringify(skillPath)}] },`, - ' })', - '}', - '', - ].join('\n')) - await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow(message) - } - await ctx.fiber.dispose() - }) - - it('rejects duplicate builtin ownership and preserves a later replacement on teardown', async () => { - const ctx = new Context() - await ctx.plugin(Loader) - const registrar = ctx.plugin(RepositoryPlugin) - await registrar - await expect(RepositoryPlugin.apply(ctx)).rejects.toThrow('already registered') - - const replacement = { name: 'replacement', apply() {} } - ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN] = replacement - await registrar.dispose() - expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBe(replacement) - await ctx.fiber.dispose() - }) -}) - -describe('configured GitHub repository sources', () => { - it('defaults an omitted source list and rejects unknown configuration fields', () => { - expect(RepositoryPlugin.Config.parse(undefined)).toEqual({ repositories: [] }) - expect(RepositoryPlugin.Config.safeParse({ repositories: [], unexpected: true }).success).toBe(false) - }) - - it('accepts an empty direct-apply config', async () => { - const ctx = new Context() - await ctx.plugin(Loader) - await RepositoryPlugin.apply(ctx, {}) - expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined() - await ctx.fiber.dispose() - }) - - it('adds the root plugin subpath and preserves an explicit nested plugin subpath', () => { - expect(resolveRepositorySpecifier('github:PolyArch/humanize#v1.0.0')) - .toBe('github:PolyArch/humanize#v1.0.0&path:/.dsh-plugin') - expect(resolveRepositorySpecifier('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin')) - .toBe('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin') - }) - - it('rejects absent refs and invalid plugin subpaths', () => { - for (const source of [ - 'github:owner/repository', - 'github:owner/repository#', - 'github:owner/repository#a#b', - 'https://github.com/owner/repository#ref', - 'github:owner/repository#ref&path:relative/.dsh-plugin', - ]) { - expect(() => resolveRepositorySpecifier(source)).toThrow('must use github:owner/repo#<ref>') - } - for (const path of [ - '/plugins//.dsh-plugin', - '/plugins/../.dsh-plugin', - '/plugins/./.dsh-plugin', - '/plugins/not-a-plugin', - ]) { - expect(() => resolveRepositorySpecifier(`github:owner/repository#ref&path:${path}`)) - .toThrow('path must be an absolute repository subpath') - } - }) - - it('resolves the default cache under DSH_HOME and an explicit cache absolutely', async () => { - const root = await temporaryDirectory('cache-root') - vi.stubEnv('DSH_HOME', root) - expect(resolveRepositoryCacheDirectory(undefined)).toBe(join(root, 'cache', 'repository-plugins')) - expect(resolveRepositoryCacheDirectory(join(root, 'explicit'))).toBe(join(root, 'explicit')) - }) - - it('loads a configured source through the immutable cache and removes its skill on teardown', async () => { - const root = await temporaryDirectory('configured-source') - await writeSkill(join(root, 'skills'), 'configured-repository-skill') - const directory = await writePlugin(root, 'configured-source-fixture', { skills: ['../skills'] }) - await RepositoryPlugin.prepareDshPlugin(directory) - const resolved: string[] = [] - const cacheDirectory = join(root, 'cache') - vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async function (this: RepositoryCache, specifier) { - expect(this.directory).toBe(cacheDirectory) - resolved.push(specifier) - return directory - }) - - const ctx = new Context() - await ctx.plugin(Loader) - await ctx.plugin(SkillService) - const registrar = ctx.plugin(RepositoryPlugin, { - repositories: ['github:owner/repository#fixed-ref'], - cacheDir: cacheDirectory, - }) - await registrar - expect(resolved).toEqual(['github:owner/repository#fixed-ref&path:/.dsh-plugin']) - await expect(ctx.skills.get('configured-repository-skill')).resolves.toMatchObject({ - provider: 'repository:configured-source-fixture', - }) - - await registrar.dispose() - await expect(ctx.skills.get('configured-repository-skill')).resolves.toBeUndefined() - await ctx.fiber.dispose() - }) - - it('swaps generations on a live source-list update and rolls a failed candidate back', async () => { - // The headline flow: a personal-config edit reaches this plugin as a - // Loader entry.update, which restarts the row's fiber (old cleanup, then - // new apply — so the 'already registered' builtin guard must not fire). - const roots: Record<string, string> = {} - for (const generation of ['one', 'two'] as const) { - const root = await temporaryDirectory(`live-${generation}`) - await writeSkill(join(root, 'skills'), `live-skill-${generation}`) - const directory = await writePlugin(root, `live-fixture-${generation}`, { skills: ['../skills'] }) - await RepositoryPlugin.prepareDshPlugin(directory) - roots[`github:owner/repository#${generation}&path:/.dsh-plugin`] = directory - } - vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async (specifier) => { - const directory = roots[specifier] - if (directory === undefined) throw new Error(`unprepared generation ${specifier}`) - return directory - }) - - // Route the row through the Loader builtin table exactly as a config tree - // would; the module itself is the row's plugin. - const ctx2 = new Context() - await ctx2.plugin(Loader) - await ctx2.plugin(SkillService) - ctx2.loader.builtins['repository-plugins'] = RepositoryPlugin - const entryId = await ctx2.loader.create({ - name: 'cordis:repository-plugins', - config: { repositories: ['github:owner/repository#one'] }, - }) - await ctx2.loader.await() - await expect(ctx2.skills.get('live-skill-one')).resolves.toMatchObject({ provider: 'repository:live-fixture-one' }) - - const entry = ctx2.loader.resolve(entryId) - await entry.update({ config: { repositories: ['github:owner/repository#two'] } }) - await ctx2.loader.await() - await expect(ctx2.skills.get('live-skill-one')).resolves.toBeUndefined() - await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' }) - - // A failed candidate (unprepared source) rejects the update and the - // transactional Loader restores the previous generation. - await expect(entry.update({ config: { repositories: ['github:owner/repository#missing'] } })) - .rejects.toThrow('unprepared generation') - await ctx2.loader.await() - await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' }) - await ctx2.fiber.dispose() - }) - - it('rejects duplicate generations and cleans the builtin after cache preparation fails', async () => { - const ctx = new Context() - await ctx.plugin(Loader) - await expect(RepositoryPlugin.apply(ctx, { - repositories: [ - 'github:owner/repository#ref', - 'github:owner/repository#ref', - ], - })).rejects.toThrow('must resolve to unique exact specifiers') - - vi.spyOn(RepositoryCache.prototype, 'resolve').mockRejectedValue(new Error('prepare failed')) - await expect(RepositoryPlugin.apply(ctx, { - repositories: ['github:owner/repository#other'], - })).rejects.toThrow('prepare failed') - expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined() - await ctx.fiber.dispose() - }) - - it('rejects a wrapper left pending by a composition without its required services', async () => { - // A skills-declaring generation mounted where no skills service exists: - // the wrapper fiber stays PENDING, and the transaction must fail loud - // instead of committing an ACTIVE row over a silently inert child. - const root = await temporaryDirectory('pending-services') - await writeSkill(join(root, 'skills'), 'pending-service-skill') - const directory = await writePlugin(root, 'pending-service-fixture', { skills: ['../skills'] }) - await RepositoryPlugin.prepareDshPlugin(directory) - - const ctx = new Context() - await ctx.plugin(Loader) - // Deliberately NO SkillService. - await expect(loadPreparedRepository(ctx, { resolve: async () => directory }, 'github:owner/repository#pending&path:/.dsh-plugin')) - .rejects.toMatchObject({ - message: expect.stringContaining('failed to load prepared repository Plugin') as string, - cause: expect.objectContaining({ - message: expect.stringContaining('waiting for services: skills') as string, - }) as Error, - }) - await ctx.fiber.dispose() - }) - - it('labels a missing prepared wrapper with its exact source and path', async () => { - const root = await temporaryDirectory('missing-wrapper') - const directory = await writePlugin(root, 'missing-wrapper', { skills: ['../skills'] }) - const ctx = new Context() - const specifier = 'github:owner/repository#missing&path:/.dsh-plugin' - await expect(loadPreparedRepository(ctx, { resolve: async () => directory }, specifier)) - .rejects.toThrow(`failed to load prepared repository Plugin ${JSON.stringify(specifier)}`) - await ctx.fiber.dispose() - }) - - it('rejects installed source with the obsolete prepare lifecycle', async () => { - const root = await temporaryDirectory('installed-lifecycle') - await writeFile(join(root, 'package.json'), JSON.stringify({ - name: 'installed-lifecycle', - devDependencies: { [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1' }, - scripts: { prepare: 'dsh-plugin-prepare' }, - })) - const ctx = new Context() - await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#old&path:/.dsh-plugin')) - .rejects.toMatchObject({ - cause: expect.objectContaining({ - message: expect.stringContaining('must declare a non-empty scripts.prepack') as string, - }) as Error, - }) - await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#old&path:/.dsh-plugin')) - .rejects.toMatchObject({ - cause: expect.objectContaining({ - message: expect.stringContaining('Clear the matching repository cache generation') as string, - }) as Error, - }) - await ctx.fiber.dispose() - }) - - it('rejects an installed source whose prepack omits the package prepare command', async () => { - const root = await temporaryDirectory('installed-skipped-prepare') - await writeFile(join(root, 'package.json'), JSON.stringify({ - name: 'installed-skipped-prepare', - devDependencies: { [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1' }, - scripts: { prepack: 'npm run build' }, - })) - const ctx = new Context() - await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#unprepared&path:/.dsh-plugin')) - .rejects.toMatchObject({ - cause: expect.objectContaining({ - message: expect.stringContaining('must invoke dsh-plugin-prepare') as string, - }) as Error, - }) - await ctx.fiber.dispose() - }) - - it('rejects installed source without the declared prepare dependency', async () => { - const root = await temporaryDirectory('installed-missing-prepare-dependency') - await writeFile(join(root, 'package.json'), JSON.stringify({ - name: 'installed-missing-prepare-dependency', - scripts: { prepack: 'dsh-plugin-prepare' }, - })) - const ctx = new Context() - await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#ambient-helper&path:/.dsh-plugin')) - .rejects.toMatchObject({ - cause: expect.objectContaining({ - message: expect.stringContaining(`${JSON.stringify(RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME)} in devDependencies`) as string, - }) as Error, - }) - await ctx.fiber.dispose() - }) - - it('labels missing installed package metadata with its source', async () => { - const root = await temporaryDirectory('missing-installed-metadata') - const ctx = new Context() - const specifier = 'github:owner/repository#damaged&path:/.dsh-plugin' - await expect(loadPreparedRepository(ctx, { resolve: async () => root }, specifier)) - .rejects.toMatchObject({ - message: expect.stringContaining(JSON.stringify(specifier)) as string, - cause: expect.objectContaining({ - message: expect.stringContaining('failed to read installed DSH plugin package metadata') as string, - }) as Error, - }) - await ctx.fiber.dispose() - }) -}) - -describe('repository plugin invariant companion', () => { - it('registers its explained empty invariant', async () => { - const ctx = new Context() - await ctx.plugin(InvariantService, { enabled: true }) - await expect(ctx.plugin(RepositoryPluginInvariant).await()).resolves.toBeDefined() - await ctx.fiber.dispose() - }) -}) diff --git a/packages/self-modification/repository-plugin/tsconfig.json b/packages/self-modification/repository-plugin/tsconfig.json deleted file mode 100644 index 67cb0dedf2..0000000000 --- a/packages/self-modification/repository-plugin/tsconfig.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/loader" - }, - { - "path": "../../skill/skill-local" - }, - { - "path": "../../mcp/mcp-client" - }, - { - "path": "../../util/paths" - }, - { - "path": "../../support/invariants" - } - ] -} diff --git a/packages/self-modification/repository-plugin/tsdown.config.ts b/packages/self-modification/repository-plugin/tsdown.config.ts deleted file mode 100644 index ac8e9a5fe0..0000000000 --- a/packages/self-modification/repository-plugin/tsdown.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from 'tsdown' - -/** Build the runtime, invariant, and prepare executable as self-contained entries. */ -export default defineConfig([ - { - entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', - fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - }, - { - entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', - fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - }, - { - entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', - fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - }, -]) diff --git a/packages/self-modification/tool-cordis/README.i18n.yaml b/packages/self-modification/tool-cordis/README.i18n.yaml index ef1f441711..67d3737753 100644 --- a/packages/self-modification/tool-cordis/README.i18n.yaml +++ b/packages/self-modification/tool-cordis/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/self-modification/tool-cordis/README.md -README.md: f2a65043a1d2f74553e98caf59ed3d38b5a70b7c -README.zh.md: 66742094992d219ccfbd60b935dcd10e48cb12b8 +README.md: 4f856523cca4800cdbb98951183fbea1e3c96c87 +README.zh.md: 7bb21396452ddbe49cf3008cd89cd6044b3c4a51 diff --git a/packages/self-modification/tool-cordis/README.md b/packages/self-modification/tool-cordis/README.md index f2a65043a1..4f856523cc 100644 --- a/packages/self-modification/tool-cordis/README.md +++ b/packages/self-modification/tool-cordis/README.md @@ -14,7 +14,7 @@ Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-cata Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native rendering says whether the temporary Plugin is running or pending and that it remains available until unmounted or DSH restarts; unmount confirms that it was removed. -Temporary Plugins live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_unmount`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. +Temporary Plugins live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_unmount`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. ## Trust stance diff --git a/packages/self-modification/tool-cordis/README.zh.md b/packages/self-modification/tool-cordis/README.zh.md index 6674209499..7bb2139645 100644 --- a/packages/self-modification/tool-cordis/README.zh.md +++ b/packages/self-modification/tool-cordis/README.zh.md @@ -14,7 +14,7 @@ 规范成功结果分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生渲染会说明临时插件正在运行还是等待中,并说明它可用至被卸载或 DSH 重启;卸载结果确认它已移除。 -临时插件只存在于共享 DSH 进程内存中。它可跨后续轮次保持活跃,也可能影响同一进程中的其他会话,但会在 `cordis_unmount`、工具集卸载或 DSH 重启后消失。它不会创建插件文件、安装任何包、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式插件。若要保留实验结果,应让 agent(智能体)通过常规开发流程实现普通的本地、项目或仓库插件。 +临时插件只存在于共享 DSH 进程内存中。它可跨后续轮次保持活跃,也可能影响同一进程中的其他会话,但会在 `cordis_unmount`、工具集卸载或 DSH 重启后消失。它不会创建插件文件、安装任何包、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式插件。若要保留实验结果,应让 agent(智能体)通过常规开发流程实现 SDK 插件或可安装的 profile 组合包。 ## 信任立场 diff --git a/packages/self-modification/tool-cordis/src/index.ts b/packages/self-modification/tool-cordis/src/index.ts index 6ba56ccfe4..46c51fa6ad 100644 --- a/packages/self-modification/tool-cordis/src/index.ts +++ b/packages/self-modification/tool-cordis/src/index.ts @@ -112,7 +112,7 @@ export function apply(ctx: Context, config: Config): void { + 'This creates an in-memory runtime Plugin, not an installed or configured Plugin. ' + 'It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. ' + 'It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. ' - + 'To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. ' + + 'To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. ' + 'It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. ' + '`code` runs now as the body of an async JavaScript function ' + 'in an isolated sandbox and MUST `return` a plugin. Two forms: ' diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index ed7a501145..38d25a6d7c 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md -README.md: aa25278750b5a1577eb567e50344fb3af425d71a -README.zh.md: 59abd5623da189d0b5d739eec56e034b553690b9 +README.md: 877b784353998a191a2d1a377aaf5406e1a97965 +README.zh.md: e6f07b2f630ecd1e6261f32c6e58254c14bb450c diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index aa25278750..877b784353 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -38,7 +38,7 @@ Default roots are resolved in this provider's rank order: | 400 | `user-dsh` | `<dshHome>/skills` | | 500 | `user-agents` | `<agentsHome>/skills` | -The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. `includeDefaultRoots: false` omits the project and user rows and the `$DSH_BUNDLED_SKILL_DIR` environment default while retaining explicitly configured custom and bundled roots, allowing several uniquely named isolated providers such as immutable repository Plugins to see only their own roots. This provider supplies project and user skills; another provider may supply built-in system skills. +The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. `includeDefaultRoots: false` omits the project and user rows and the `$DSH_BUNDLED_SKILL_DIR` environment default while retaining explicitly configured custom and bundled roots, allowing several uniquely named isolated providers to see only their own roots. This provider supplies project and user skills; another provider may supply built-in system skills. When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Confirmed missing paths are valid empty state, malformed or non-text entries warn and skip, and unexpected discovery/read failures make the registry snapshot incomplete rather than replacing a last-good model catalog with a misleading deletion. diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index 59abd5623d..e6f07b2f63 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -38,7 +38,7 @@ | 400 | `user-dsh` | `<dshHome>/skills` | | 500 | `user-agents` | `<agentsHome>/skills` | -项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目根、用户根以及 `$DSH_BUNDLED_SKILL_DIR` 环境默认值,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个只看到自身根的唯一命名隔离提供方,例如不可变的仓库插件。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 +项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目根、用户根以及 `$DSH_BUNDLED_SKILL_DIR` 环境默认值,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个只看到自身根的唯一命名隔离提供方。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;遇到格式错误或非文本条目时,提供方会发出警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录。 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 996a12329f..912feaa2ca 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -166,9 +166,8 @@ export class LocalSkillProvider implements SkillProvider { this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config)) control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true }) // The environment bundled root is a default root: an isolated provider - // (includeDefaultRoots: false — repository plugins) must see only its - // explicit custom roots, or every such provider would re-discover the - // app's bundled skills and claim them under its own provider name. + // must see only its explicit roots, or every such provider would + // re-discover the app's bundled skills under its own provider name. const bundledSkillDir = config.bundledSkillDir ?? (this.includeDefaultRoots ? process.env.DSH_BUNDLED_SKILL_DIR : undefined) this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f36be6b1b6..732e00e4ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -517,9 +517,6 @@ importers: '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:* version: link:../packages/guard/repeat-tool-guard - '@deepseek-ai/dsh-repository-plugin': - specifier: workspace:* - version: link:../packages/self-modification/repository-plugin '@deepseek-ai/dsh-sandbox': specifier: workspace:* version: link:../packages/sandbox/sandbox @@ -1263,9 +1260,6 @@ importers: '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../guard/repeat-tool-guard - '@deepseek-ai/dsh-repository-plugin': - specifier: workspace:^ - version: link:../../self-modification/repository-plugin '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../sandbox/sandbox-local @@ -5517,40 +5511,6 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis - packages/self-modification/repository-plugin: - dependencies: - zod: - specifier: ^4.4.3 - version: 4.4.3 - devDependencies: - '@cordisjs/plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-mcp-client': - specifier: workspace:^ - version: link:../../mcp/mcp-client - '@deepseek-ai/dsh-paths': - specifier: workspace:^ - version: link:../../util/paths - '@deepseek-ai/dsh-skill': - specifier: workspace:^ - version: link:../../skill/skill - '@deepseek-ai/dsh-skill-local': - specifier: workspace:^ - version: link:../../skill/skill-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - packages/self-modification/tool-cordis: dependencies: schemastery: @@ -7978,9 +7938,6 @@ importers: node-addon-require-builtin: specifier: ^0.1.4 version: 0.1.4 - pnpm: - specifier: 11.7.0 - version: 11.7.0 vendor/logger-console: dependencies: @@ -12638,11 +12595,6 @@ packages: engines: {node: '>=18'} hasBin: true - pnpm@11.7.0: - resolution: {integrity: sha512-GcyFLBIMcSV2DyRD7mvgyltA+fUFmN4aCaHxd1A+AQ5Xwjx3ZG4B52HeWb+HT7IqM5jDOrlpH8E+uUa28PTWIA==} - engines: {node: '>=22.13'} - hasBin: true - points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -18146,8 +18098,6 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - pnpm@11.7.0: {} - points-on-curve@0.2.0: {} points-on-path@0.2.1: diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 7c85d8ff48..6ef494b76b 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -227,7 +227,7 @@ describe('Node 24 lane ownership', () => { const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers')) expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({ - workers: 11, + workers: 10, source: 'ci-consumers gate count', }) expect(subject.map(item => item.id)).toEqual([ @@ -241,7 +241,6 @@ describe('Node 24 lane ownership', () => { 'doc-typecheck', 'node-next-types', 'built-bin-smoke', - 'github-repository-plugin-e2e', ]) expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build']) expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) @@ -252,7 +251,6 @@ describe('Node 24 lane ownership', () => { 'doc-typecheck', 'node-next-types', 'built-bin-smoke', - 'github-repository-plugin-e2e', ]) { expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) } @@ -266,16 +264,6 @@ describe('Node 24 lane ownership', () => { 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', ]), ) - const githubRepositoryPlugin = subject.find(item => item.id === 'github-repository-plugin-e2e') - expect(githubRepositoryPlugin).toMatchObject({ - label: 'GitHub repository Plugin dsh run', - env: { - DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E: '1', - }, - }) - expect(githubRepositoryPlugin?.args).toEqual( - expect.arrayContaining(['apps/cli/tests/github-repository-plugin.built.e2e.ts']), - ) expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({ displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', env: { DSH_SNAPSHOT: 'replay' }, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 3b88da217f..b986b2e0f3 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -406,7 +406,6 @@ function ciConsumerGates(): Gate[] { needs: validatedBuild, }), builtBinSmokeGate(validatedBuild), - githubRepositoryPluginE2eGate(validatedBuild), ] } @@ -518,7 +517,7 @@ function coverageGates(): Gate[] { } // Example and package snapshots boot their bins in `lib` mode (built artifacts under plain Node, -// plugins via real exports); repository-script snapshots execute their real source entry path. +// plugins via real exports); script snapshots execute their real source entry path. // Callers wait either on `build` or on a validation gate that transitively owns that build. function snapshotGate(needs: string[] = ['build']): Gate { return pnpmScript('snapshot', 'test:snapshot', { @@ -639,20 +638,6 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { }) } -function githubRepositoryPluginE2eGate(needs: string[]): Gate { - return pnpmExec('github-repository-plugin-e2e', [ - 'vitest', - 'run', - '--config', - 'vitest.e2e.config.ts', - 'apps/cli/tests/github-repository-plugin.built.e2e.ts', - ], { - label: 'GitHub repository Plugin dsh run', - needs, - env: { DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E: '1' }, - }) -} - /** * Reject a gate list whose graph cannot be executed unambiguously. * @param gates - complete aggregate to validate. diff --git a/tsconfig.host.json b/tsconfig.host.json index d9bf1c29e4..7b4616367e 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -253,7 +253,6 @@ { "path": "./packages/preset/persona" }, { "path": "./packages/guard/repeat-tool-guard" }, { "path": "./packages/self-modification/tool-cordis" }, - { "path": "./packages/self-modification/repository-plugin" }, { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, { "path": "./packages/hooks/hooks-codex" }, diff --git a/vendor/README.md b/vendor/README.md index 75f93b2b88..87e5859212 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,12 +39,11 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. -10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. A transaction-owned `pnpm` wrapper and exported `PNPM_CONFIG_IGNORE_WORKSPACE` make pnpm's nested Git-package install reinvoke the same bundled entry outside an enclosing source workspace. The child retains `PNPM_HOME` for pnpm data while removing that directory from lifecycle `PATH`, and prioritizes `.CMD` in `PATHEXT` so a later inherited pnpm executable cannot outrank the wrapper on Windows. The temporary command directory is removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile, obtains both its build and prepare commands from declared dependencies, and rejects an inherited shadow pnpm. -11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. -12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. -13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. -14. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. -15. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. +10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. +11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. +12. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. +13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. +14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. ## Sync procedure diff --git a/vendor/loader/package.json b/vendor/loader/package.json index 509c558ed3..1d4e757099 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -11,16 +11,11 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./repository": { - "types": "./lib/types/repository.d.ts", - "default": "./lib/repository.js" - }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/repository.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -37,7 +32,6 @@ } }, "dependencies": { - "cosmokit": "^1.8.1", - "pnpm": "11.7.0" + "cosmokit": "^1.8.1" } } diff --git a/vendor/loader/src/repository.ts b/vendor/loader/src/repository.ts deleted file mode 100644 index c85b84ccfc..0000000000 --- a/vendor/loader/src/repository.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * Exact-specifier repository packages installed through the Loader's bundled - * pnpm. The caller owns source validation and the cache root; this module owns - * isolated installation, single-flight reuse, and atomic cache publication. - */ - -import { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { delimiter, dirname, join, resolve } from 'node:path' - -/** Exact pnpm release shipped with the Loader for repository installation. */ -export const BUNDLED_PNPM_VERSION = '11.7.0' - -const DEPENDENCY_NAME = 'repository' -const MARKER_NAME = '.repository-cache.json' -const MAX_ERROR_OUTPUT = 32 * 1024 -const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i - -/** Injectable isolated-install boundary used by {@link RepositoryCache}. */ -export type RepositoryInstall = (directory: string) => Promise<void> - -/** Installation controls for {@link RepositoryCache}. */ -export interface RepositoryCacheOptions { - /** Override the isolated package installation boundary. */ - install?: RepositoryInstall -} - -interface CacheMarker { - specifier: string -} - -function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name))) -} - -function normalizedEnvironmentPath(value: string): string { - const unquoted = value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value - const normalized = resolve(unquoted) - return process.platform === 'win32' ? normalized.toUpperCase() : normalized -} - -function installEnvironment(commandDirectory: string): NodeJS.ProcessEnv { - const scrubbed = scrubEnvironment() - const path = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PATH')?.[1] - const pathExt = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PATHEXT')?.[1] - const pnpmHome = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PNPM_HOME')?.[1] - const normalizedPnpmHome = pnpmHome === undefined ? undefined : normalizedEnvironmentPath(pnpmHome) - const inheritedPath = path === undefined ? [] : path.split(delimiter).filter((entry) => { - return normalizedPnpmHome === undefined || normalizedEnvironmentPath(entry) !== normalizedPnpmHome - }) - const pathExtensions = pathExt?.split(';') - const prioritizedPathExt = pathExtensions === undefined ? undefined : [ - ...pathExtensions.filter(extension => extension.toUpperCase() === '.CMD'), - ...pathExtensions.filter(extension => extension.toUpperCase() !== '.CMD'), - ].join(';') - const withoutOverrides = Object.fromEntries(Object.entries(scrubbed).filter(([name]) => { - return !['PATH', 'PATHEXT', 'PNPM_CONFIG_IGNORE_WORKSPACE'].includes(name.toUpperCase()) - })) - return { - ...withoutOverrides, - PATH: [commandDirectory, ...inheritedPath].join(delimiter), - // cmd.exe tests PATHEXT before later PATH entries, so the transaction's - // pnpm.cmd must precede an inherited pnpm executable from PNPM_HOME. - ...(prioritizedPathExt === undefined ? {} : { PATHEXT: prioritizedPathExt }), - PNPM_CONFIG_IGNORE_WORKSPACE: 'true', - } -} - -function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'` -} - -function batchQuote(value: string): string { - return `"${value.replaceAll('%', '%%')}"` -} - -function appendOutput(current: string, chunk: Uint8Array): string { - const combined = current + Buffer.from(chunk).toString('utf8') - return combined.length <= MAX_ERROR_OUTPUT ? combined : combined.slice(-MAX_ERROR_OUTPUT) -} - -async function installWithBundledPnpm(directory: string): Promise<void> { - const require = createRequire(import.meta.url) - const pnpmManifest = require.resolve('pnpm') - const pnpmBin = join(dirname(pnpmManifest), 'bin', 'pnpm.mjs') - const commandDirectory = await mkdtemp(join(tmpdir(), 'cordis-repository-pnpm-')) - try { - await Promise.all([ - writeFile(join(commandDirectory, 'pnpm'), [ - '#!/bin/sh', - `exec ${shellQuote(process.execPath)} ${shellQuote(pnpmBin)} --ignore-workspace "$@"`, - '', - ].join('\n'), { mode: 0o700 }), - writeFile(join(commandDirectory, 'pnpm.cmd'), [ - '@echo off', - `${batchQuote(process.execPath)} ${batchQuote(pnpmBin)} --ignore-workspace %*`, - '', - ].join('\r\n'), { mode: 0o700 }), - ]) - let output = '' - const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { - const child = spawn(process.execPath, [ - pnpmBin, - 'install', - '--no-frozen-lockfile', - '--reporter=append-only', - ], { - cwd: directory, - env: installEnvironment(commandDirectory), - shell: false, - stdio: ['ignore', 'pipe', 'pipe'], - }) - child.stdout.on('data', (chunk: Uint8Array) => { output = appendOutput(output, chunk) }) - child.stderr.on('data', (chunk: Uint8Array) => { output = appendOutput(output, chunk) }) - child.once('error', reject) - child.once('close', (code, signal) => { resolve({ code, signal }) }) - }) - if (result.signal !== null) { - throw new Error(`bundled pnpm install was killed by ${result.signal}${output ? `\n${output.trimEnd()}` : ''}`) - } - if (result.code !== 0) { - throw new Error(`bundled pnpm install exited with code ${String(result.code)}${output ? `\n${output.trimEnd()}` : ''}`) - } - } finally { - await rm(commandDirectory, { recursive: true, force: true }) - } -} - -function cacheKey(specifier: string): string { - return createHash('sha256').update(specifier).digest('hex') -} - -async function readCached(directory: string, specifier: string): Promise<string | undefined> { - let content: string - try { - content = await readFile(join(directory, MARKER_NAME), 'utf8') - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return - throw error - } - let parsed: unknown - try { - parsed = JSON.parse(content) as unknown - } catch (error) { - throw new Error(`repository cache marker is invalid: ${join(directory, MARKER_NAME)}`, { cause: error }) - } - if (typeof parsed !== 'object' || parsed === null || typeof (parsed as Partial<CacheMarker>).specifier !== 'string') { - throw new Error(`repository cache marker is invalid: ${join(directory, MARKER_NAME)}`) - } - const marker = parsed as CacheMarker - if (marker.specifier !== specifier) { - throw new Error(`repository cache key collision for ${JSON.stringify(specifier)}`) - } - const packageDirectory = join(directory, 'node_modules', DEPENDENCY_NAME) - let packageStat - try { - packageStat = await stat(packageDirectory) - } catch (error) { - throw new Error(`repository cache entry is incomplete: ${directory}`, { cause: error }) - } - if (!packageStat.isDirectory()) throw new Error(`repository cache package is not a directory: ${packageDirectory}`) - return packageDirectory -} - -async function removeStaging(directory: string, cause: unknown): Promise<never> { - try { - await rm(directory, { recursive: true, force: true }) - } catch (cleanupError) { - throw new AggregateError([cause, cleanupError], `failed to clean repository staging directory ${directory}`) - } - throw cause -} - -/** - * Persistent exact-specifier package cache backed by bundled pnpm. - * - * One isolated project contains one dependency named `repository`. A successful - * install is atomically renamed into its SHA-256 key, so failed installs never - * become cache hits. The exact specifier is immutable: callers change the - * specifier (normally its Git ref) to request another generation. - */ -export class RepositoryCache { - /** Absolute directory containing immutable repository cache entries. */ - readonly directory: string - - private readonly tasks = new Map<string, Promise<string>>() - private readonly install: RepositoryInstall - - /** - * @param directory - caller-owned persistent cache root. - * @param options - isolated installer override. - */ - constructor(directory: string, options: RepositoryCacheOptions = {}) { - this.directory = resolve(directory) - this.install = options.install ?? installWithBundledPnpm - } - - /** - * Resolve one package-manager-native dependency specifier to its installed package directory. - * @param specifier - exact immutable dependency specifier used as the permanent cache identity. - * @returns the installed `repository` dependency directory. - * @throws when the specifier is empty/padded, installation fails, or a published cache entry is corrupt. - */ - resolve(specifier: string): Promise<string> { - if (!specifier || specifier.trim() !== specifier) { - throw new TypeError('repository specifier must be a non-empty unpadded string') - } - const existing = this.tasks.get(specifier) - if (existing) return existing - const task = this.resolveUncached(specifier).finally(() => { - if (this.tasks.get(specifier) === task) this.tasks.delete(specifier) - }) - this.tasks.set(specifier, task) - return task - } - - private async resolveUncached(specifier: string): Promise<string> { - const finalDirectory = join(this.directory, cacheKey(specifier)) - const cached = await readCached(finalDirectory, specifier) - if (cached) return cached - - await mkdir(this.directory, { recursive: true }) - const staging = await mkdtemp(join(this.directory, '.repository-')) - try { - await writeFile(join(staging, 'package.json'), `${JSON.stringify({ - name: 'cordis-repository-cache-entry', - private: true, - version: '0.0.0', - packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`, - dependencies: { [DEPENDENCY_NAME]: specifier }, - }, undefined, 2)}\n`) - await writeFile(join(staging, 'pnpm-workspace.yaml'), [ - 'packages: []', - 'dangerouslyAllowAllBuilds: true', - '', - ].join('\n')) - await this.install(staging) - const packageDirectory = join(staging, 'node_modules', DEPENDENCY_NAME) - const packageStat = await stat(packageDirectory) - if (!packageStat.isDirectory()) throw new Error(`installed repository is not a directory: ${packageDirectory}`) - await writeFile(join(staging, MARKER_NAME), `${JSON.stringify({ specifier })}\n`) - try { - await rename(staging, finalDirectory) - } catch (error) { - const winner = await readCached(finalDirectory, specifier) - if (!winner) throw error - await rm(staging, { recursive: true, force: true }) - return winner - } - } catch (error) { - return removeStaging(staging, new Error(`failed to prepare repository ${JSON.stringify(specifier)}`, { cause: error })) - } - return (await readCached(finalDirectory, specifier))! - } -} diff --git a/vendor/loader/tsdown.config.ts b/vendor/loader/tsdown.config.ts index 75e627cdd2..b1e43952f2 100644 --- a/vendor/loader/tsdown.config.ts +++ b/vendor/loader/tsdown.config.ts @@ -1,6 +1,5 @@ import { defineConfig } from 'tsdown' -/** Keep the browser-reachable Loader entry separate from the Node-only repository cache. */ const shared = { outDir: 'lib', format: ['esm'], @@ -14,5 +13,4 @@ const shared = { export default defineConfig([ { ...shared, entry: ['lib/types/index.js'] }, - { ...shared, entry: ['lib/types/repository.js'] }, ]) From 541591d5f686eb3447b698b2b4a8dc6c4e1233c6 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Sun, 9 Aug 2026 16:53:54 +0800 Subject: [PATCH 128/229] fix: address repository removal review --- .../2026-08-09-remove-repository-plugin.i18n.yaml | 4 ++-- .../simplification/2026-08-09-remove-repository-plugin.md | 5 +++-- .../2026-08-09-remove-repository-plugin.zh.md | 5 +++-- packages/mcp/mcp-client/src/index.ts | 8 ++------ packages/skill/skill-local/tests/skill-local.spec.ts | 2 +- tsconfig.base.json | 1 - 6 files changed, 11 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.i18n.yaml index da5b77fbd0..5317c7911e 100644 --- a/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.md -2026-08-09-remove-repository-plugin.md: 8dd2fe95ac97bc2d8ab50043eb6c516f53d3204c -2026-08-09-remove-repository-plugin.zh.md: 832a69ff206dd91dd312625bd9218e980d890ae2 +2026-08-09-remove-repository-plugin.md: 8ac6fd18b756e227f8dabc82eb4926a51702c5a1 +2026-08-09-remove-repository-plugin.zh.md: 6e504a151a88b87b8093a91a91ede1cb919a0b45 diff --git a/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.md b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.md index 8dd2fe95ac..8ac6fd18b7 100644 --- a/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.md +++ b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.md @@ -26,7 +26,7 @@ This note consolidates the removed repository cache, static format, config-only **Teach the repository wrapper to load a bundle patch.** Rejected because the repository cache and preparation protocol would still duplicate profile dependency installation. Bundle packages are already accepted from npm, Git, file, and link specifications through pnpm. -**Keep the generic Loader repository cache for possible future consumers.** Rejected because it has no current consumer after the package removal and carries a pinned package-manager runtime in a vendored browser-adjacent package. A future need can choose its cache contract from current consumers rather than preserving this one speculatively. +**Keep the generic Loader repository cache for possible future consumers.** Rejected because it has no current consumer after the package removal and carries a pinned package-manager runtime in a vendored browser-adjacent package. A dedicated cache is warranted again only if configuration-time activation without an explicit installation becomes a product requirement that profile dependencies cannot satisfy; that consumer can choose its cache contract then. **Disable repository Plugin but retain its on-disk format for migration.** Rejected under the pre-release stance. Retaining a parser or compatibility loader would keep the removed contract alive without an external compatibility obligation. @@ -34,10 +34,11 @@ This note consolidates the removed repository cache, static format, config-only - Third-party packages use one installation and composition model, with ordinary dependency declarations and full patch-level Plugin config. - Installing or updating an external bundle is an explicit `dsh plugin` package-manager operation rather than a watched source-list edit. User patch HMR still configures rows contributed by installed bundles. +- Profile installation requires `pnpm` on the host `PATH`. This is acceptable for an explicit package-management operation and avoids shipping the removed cache's pinned package-manager runtime solely for configuration-time activation. - `.dsh-plugin` packages and existing repository source-list patches stop working. Their cache files remain removable by the user but are not migrated or automatically deleted. - The dedicated pnpm runtime, preparation executable, wrapper generator, Git credential CI setup, repository cache, and repository-specific tests disappear. - Package-relative static assets need a bundle-owned path form so a declarative bundle can point `dsh-skill-local`, `dsh-mcp-client`, or another Plugin at files it ships without custom runtime glue. That capability is owned by the bundle format rather than a repository adapter. ## Testing -Static gates reject stale package, config, documentation, graph, and workspace references. The existing `dsh plugin` built-CLI acceptance covers profile initialization, package-manager installation, bundle discovery, and layer reconciliation. Bundle-specific tests own declarative asset-path resolution and real Skill/MCP composition. +Static gates reject stale package, config, documentation, graph, and workspace references. The existing `dsh plugin` built-CLI acceptance covers profile initialization, package-manager installation, bundle discovery, and layer reconciliation. Declarative package-relative Skill and MCP bundle resources remain a named coverage gap in this removal layer. diff --git a/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.zh.md b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.zh.md index 832a69ff20..6e504a151a 100644 --- a/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.zh.md @@ -26,7 +26,7 @@ DeepSeek Harness 只保留一种独立的外部插件分发路径:可安装的 **让 repository 包装层加载组合包 patch。** 不予采纳,因为 repository 缓存和准备协议仍会重复 profile 依赖安装。组合包已经可以通过 pnpm 接受 npm、Git、file 和 link 说明符。 -**为未来可能出现的消费方保留通用 Loader repository 缓存。** 不予采纳,因为在移除相关包后,它已无当前消费方,却仍让一个 vendor 中与浏览器相邻的包携带固定版本的包管理器运行时。未来若确有需要,可以根据届时的实际消费方选择缓存契约,无需推测性地保留现有契约。 +**为未来可能出现的消费方保留通用 Loader repository 缓存。** 不予采纳,因为在移除相关包后,它已无当前消费方,却仍让一个 vendor 中与浏览器相邻的包携带固定版本的包管理器运行时。只有当无需显式安装即可在配置阶段激活这一能力成为 profile 依赖无法满足的产品需求时,才有理由重新引入专用缓存;届时该消费方可以选择自己的缓存约定。 **禁用 repository 插件,但保留其磁盘格式以供迁移。** 根据预发布方针,不予采纳。保留解析器或兼容 loader 会在没有外部兼容义务的情况下,让已移除的契约继续存在。 @@ -34,10 +34,11 @@ DeepSeek Harness 只保留一种独立的外部插件分发路径:可安装的 - 第三方包统一使用一种安装与组合模型,采用普通依赖声明和完整的 patch 层插件配置。 - 安装或更新外部组合包时,必须显式通过 `dsh plugin` 执行包管理器操作,而不是编辑受监听的源列表。用户 patch 的 HMR(热模块替换)仍可配置已安装组合包所提供的配置项。 +- 安装 profile 时,宿主机的 `PATH` 中必须提供 `pnpm`。对于显式的包管理操作,这一要求可以接受,并且可避免仅为配置阶段激活而随产品交付已移除缓存所使用的固定版本包管理器运行时。 - `.dsh-plugin` 包和现有 repository 源列表 patch 停止工作。用户仍可自行删除其缓存文件,但系统不会迁移或自动删除这些文件。 - 专用 pnpm 运行时、准备工作可执行文件、包装层生成器、Git 凭据 CI 设置、repository 缓存和 repository 专用测试全部消失。 - 静态资源需要一种由组合包拥有、可相对于包解析的路径形式,使声明式组合包可以将 `dsh-skill-local`、`dsh-mcp-client` 或其他插件指向它随包交付的文件,而无需定制运行时代码。该能力归组合包格式所有,而不是 repository 适配器。 ## 测试 -静态门禁会拒绝残留的包、配置、文档、图和 workspace 引用。现有 `dsh plugin` 已构建 CLI(命令行界面)验收测试覆盖 profile 初始化、包管理器安装、组合包发现和层调和。组合包专用测试负责覆盖声明式资源路径解析,以及真实的 skill/MCP 组合。 +静态门禁会拒绝残留的包、配置、文档、图和 workspace 引用。现有 `dsh plugin` 已构建 CLI(命令行界面)验收测试覆盖 profile 初始化、包管理器安装、组合包发现和层调和。声明式、相对于包解析的 skill 与 MCP 组合包资源仍是本移除层中已明确记录的覆盖缺口。 diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index ece8c7857f..b55896544d 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -33,12 +33,8 @@ export const inject = ['tools'] /** Default timeout for individual MCP tool calls (ms). */ const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000 -/** - * Valid `serverName`: 1–32 chars of `[A-Za-z0-9_-]`. Kept well under the - * 64-char public-name budget so typical raw tool names survive unhashed. - * Exported so config producers can reject the same names this registry would. - */ -export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ +/** Valid `serverName`, kept below the public tool-name budget. */ +const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ /** * Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 8bb08d2947..5bf1d24856 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -830,7 +830,7 @@ describe('LocalSkillProvider', () => { // Isolated providers see only their explicit roots: the environment // bundled root is a default root, so includeDefaultRoots: false must - // drop it — repository providers never re-claim the app's builtins. + // drop it — isolated providers never re-claim the app's builtins. const isolated = new Context() await isolated.plugin(SkillService) const customOnly = join(envHome, 'custom-only') diff --git a/tsconfig.base.json b/tsconfig.base.json index 0523b378d9..732e740ce7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -32,7 +32,6 @@ "cosmokit": ["./vendor/cosmokit/src"], "schemastery": ["./vendor/schemastery/src"], "@cordisjs/plugin-loader": ["./vendor/loader/src"], - "@cordisjs/plugin-loader/repository": ["./vendor/loader/src/repository.ts"], "@cordisjs/plugin-include": ["./vendor/include/src"], "@cordisjs/plugin-group": ["./vendor/group/src"], "@cordisjs/plugin-timer": ["./vendor/timer/src"], From a33ee3d4f098ffbe9a2617c4eadb12a0282910c5 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Sun, 9 Aug 2026 17:01:03 +0800 Subject: [PATCH 129/229] docs: refresh MCP config catalog --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 889b2e24e7..a4cdb4a414 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 471680f92dc44f3dd4e98ba9e946525ec79f25b0 -config-catalog.zh.md: bf78766799b02a1f6f21f935723abace108bc306 +config-catalog.md: 10078dea5221cfe76b1c028ef83216f5a4575940 +config-catalog.zh.md: 40153ab6a1a8a98fdaaebd0a42906557430b39d6 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 842b96d950..10078dea52 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1109,7 +1109,7 @@ export interface StreamableHttpConfig { } ``` -Source: [`packages/mcp/mcp-client/src/index.ts:98`](../packages/mcp/mcp-client/src/index.ts) +Source: [`packages/mcp/mcp-client/src/index.ts:94`](../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 bf78766799..40153ab6a1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1111,7 +1111,7 @@ export interface StreamableHttpConfig { } ``` -来源:[`packages/mcp/mcp-client/src/index.ts:100`](../packages/mcp/mcp-client/src/index.ts) +来源:[`packages/mcp/mcp-client/src/index.ts:94`](../packages/mcp/mcp-client/src/index.ts) ## `@deepseek-ai/dsh-permission` From 820bb290357f2918a6d35fff0357ddf9b01dd7d3 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Mon, 10 Aug 2026 21:31:04 +0800 Subject: [PATCH 130/229] docs: sync repository removal translations --- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.zh.md | 16 ---------------- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.zh.md | 6 ------ docs/tool-catalog.i18n.yaml | 4 ++-- docs/tool-catalog.zh.md | 2 +- 6 files changed, 6 insertions(+), 28 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index a4cdb4a414..02e95f011e 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -3,4 +3,4 @@ # 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: 10078dea5221cfe76b1c028ef83216f5a4575940 -config-catalog.zh.md: 40153ab6a1a8a98fdaaebd0a42906557430b39d6 +config-catalog.zh.md: 38ed9f94bee9b0c11f5ea4dad4ba48532cce6450 diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 40153ab6a1..38ed9f94be 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1308,22 +1308,6 @@ export interface Config { 来源:[`packages/guard/repeat-tool-guard/src/index.ts:28`](../packages/guard/repeat-tool-guard/src/index.ts) -## `@deepseek-ai/dsh-repository-plugin` - -需要:`loader` - -```ts config-catalog -/** Repository Plugin runtime and source-list configuration. */ -export interface Config { - /** GitHub repository sources with explicit refs and optional `.dsh-plugin` subpaths. */ - repositories?: string[] - /** Persistent generation cache; defaults to `$DSH_HOME/cache/repository-plugins`. */ - cacheDir?: string -} -``` - -来源:[`packages/self-modification/repository-plugin/src/index.ts:44`](../packages/self-modification/repository-plugin/src/index.ts) - ## `@deepseek-ai/dsh-sandbox-local` ```ts config-catalog diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 77fcfca867..8123225d51 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: b596cb564943e321ff42cd59855bd7dc10981319 -module-graph.zh.md: b64f30c2df1eedb1a1fa106b79710558b765fdde +module-graph.md: 9f2780c57aa78dabb6de5ff3d3d840b34c888ae0 +module-graph.zh.md: 8d67d9229a1498c2b39d80d32c84c4643086dd8f diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index b64f30c2df..8d67d9229a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -255,7 +255,6 @@ flowchart TD pkg_telemetry["telemetry"] end subgraph group_self_modification["packages/self-modification"] - pkg_repository_plugin["repository-plugin"] pkg_tool_cordis["tool-cordis"] end subgraph group_session["packages/session"] @@ -1071,10 +1070,6 @@ flowchart TD pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent - pkg_repository_plugin --> pkg_invariants - pkg_repository_plugin --> pkg_mcp_client - pkg_repository_plugin --> pkg_paths - pkg_repository_plugin --> pkg_skill_local pkg_tool_ralph --> pkg_agent pkg_tool_ralph --> pkg_invariants pkg_tool_ralph --> pkg_llm @@ -1432,7 +1427,6 @@ flowchart TD | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 567f95d408..b74b7fa8ea 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: dbab9ce2f389dbfe40e7d753ced995a8a384be17 -tool-catalog.zh.md: e99f8bc78923e616265427c1e0361c832cc0930f +tool-catalog.md: f61b6daeb7209d0fa81606bda7718fd42c7f22dc +tool-catalog.zh.md: cbecdba084fa64ec78913706221a1558d206cde8 diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index e99f8bc789..cbecdba084 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -286,7 +286,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 ### `cordis_mount` -在当前 DSH 进程中挂载临时 Cordis Plugin。它创建的是内存中的运行时 Plugin,而不是已安装或已配置的 Plugin。该插件会在后续轮次中保持活动,直到执行 cordis_unmount、工具集卸载或 DSH 重启。它不会创建文件、安装包、修改 cordis.yml 或个人/项目配置、在重启后保留,也不会自动转为永久插件。若要保留,请让 Agent 通过常规开发工作流实现普通的本地、项目或仓库 Plugin。它可能影响同一进程中的其他会话;沙箱不是安全边界,注入的服务会访问真实运行时。`code` 会立即作为异步 JavaScript 函数的函数体在隔离沙箱中运行,并且**必须** `return` 一个插件。支持两种形式:函数形式 `return (ctx) => { … }`,它不声明 inject,因此可以注册工具、监听事件和提供服务,但访问**任何**服务(例如 ctx.bash)都会抛出异常;仅在不需要服务时使用。对象形式 `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }`,它声明依赖,Cordis 只在服务存在后激活插件;**优先使用**这种形式。你只能访问 inject 中列出的服务:即使未声明的服务存在,访问它也会抛出异常,因为如果提供方被卸载,未声明的依赖将无法清理。代码调用服务**之前**,请读取 cordis_inspect 的 what:"api";它会列出方法签名以及参数/返回值的类型形状,不要猜测字段类型,例如 bash 运行的 stdout 是对象而非字符串。在 `apply` 内,请使用标准 Cordis API:通过 `ctx.on(event, listener)` 观察事件(见 cordis_inspect 的 what:"events"),或调用 `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` 为自己提供新工具;该工具会在你的**下一步骤**可调用。工具参数:每个键**就是**一个属性,即 { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? };每个直接 DSL 对象都声明 additionalProperties: true|false,而 oneOf: [schema, schema, ...] 会取代 type,表示恰好匹配一个成员的联合。也接受原始 JSON Schema { type: 'object', properties, required?: […] } 包装层,其中对象默认开放。工具的 `execute` **必须**返回 `output.schema` 声明的无损 JSON 值;`output.render(args, value)` 单独返回 Native/模型内容块。临时 Plugin 可以**组合**:一个 Plugin 可以通过 `ctx.provide('name', value)` 提供服务,另一个则可声明 `inject: ['name']` 来消费它;消费方会在提供方出现前保持等待,提供方卸载后重新回到等待状态。在 `apply` 中注册的一切都会由 cordis_unmount 自动清理。沙箱全局对象:`console`(带 `[cordis:<id>]` 标签,写入 harness 终端)、`harness.defineTool`、`harness.registerTool`、`btoa`、`atob`、`TextEncoder`、`TextDecoder`。Node API 已**禁用**:文件系统/网络/定时工作必须通过 Cordis 服务完成,绝不能使用 Node 内置能力;`require`、`setTimeout`/`setInterval` 和 `fetch` 会抛出重定向错误,`process` 和 `Buffer` 未定义。应改用 inject: ['fs'] + ctx.fs 处理文件、inject: ['web'] + ctx.web 处理 HTTP、inject: ['bash'] + ctx.bash 处理进程、inject: ['timer'] + ctx.setTimeout/ctx.setInterval 处理定时(这些是 fiber effect,卸载时自动清理);cordis_inspect 的 what:"api" 会展示**当前**运行时提供的能力。请编写**纯** JavaScript,不要使用 TypeScript(不得使用 `as` 或类型注解)。注意事项:(1) waterfall(瀑布式事件)事件(例如 tools/pre-execute)会向监听器传入最后一个 `next` 回调,该回调**必须**被调用;不调用 `next()` 就返回会**短路**此次调用。除非你有意拦截,否则请优先使用普通通知事件。(2) 切勿等待只能在当前轮次之后解析的内容;你的代码运行在该轮次的工具调用**内部**,否则会死锁。(3) 你的 `ctx` 是受限门面:可以注册工具、观察事件、提供/消费服务和使用定时器,但不会提供框架内部能力(ctx.root、ctx.fiber、ctx.extend、ctx.plugin 等)。不过,它并非安全边界:你注入的服务(例如 ctx.bash)会访问真实运行时。 +在当前 DSH 进程中挂载临时 Cordis Plugin。它创建的是内存中的运行时 Plugin,而不是已安装或已配置的 Plugin。该插件会在后续轮次中保持活动,直到执行 cordis_unmount、工具集卸载或 DSH 重启。它不会创建文件、安装包、修改 cordis.yml 或个人/项目配置、在重启后保留,也不会自动转为永久插件。若要保留,请让 Agent 通过常规开发工作流实现 SDK Plugin 或可安装的 profile bundle。它可能影响同一进程中的其他会话;沙箱不是安全边界,注入的服务会访问真实运行时。`code` 会立即作为异步 JavaScript 函数的函数体在隔离沙箱中运行,并且**必须** `return` 一个插件。支持两种形式:函数形式 `return (ctx) => { … }`,它不声明 inject,因此可以注册工具、监听事件和提供服务,但访问**任何**服务(例如 ctx.bash)都会抛出异常;仅在不需要服务时使用。对象形式 `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }`,它声明依赖,Cordis 只在服务存在后激活插件;**优先使用**这种形式。你只能访问 inject 中列出的服务:即使未声明的服务存在,访问它也会抛出异常,因为如果提供方被卸载,未声明的依赖将无法清理。代码调用服务**之前**,请读取 cordis_inspect 的 what:"api";它会列出方法签名以及参数/返回值的类型形状,不要猜测字段类型,例如 bash 运行的 stdout 是对象而非字符串。在 `apply` 内,请使用标准 Cordis API:通过 `ctx.on(event, listener)` 观察事件(见 cordis_inspect 的 what:"events"),或调用 `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` 为自己提供新工具;该工具会在你的**下一步骤**可调用。工具参数:每个键**就是**一个属性,即 { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? };每个直接 DSL 对象都声明 additionalProperties: true|false,而 oneOf: [schema, schema, ...] 会取代 type,表示恰好匹配一个成员的联合。也接受原始 JSON Schema { type: 'object', properties, required?: […] } 包装层,其中对象默认开放。工具的 `execute` **必须**返回 `output.schema` 声明的无损 JSON 值;`output.render(args, value)` 单独返回 Native/模型内容块。临时 Plugin 可以**组合**:一个 Plugin 可以通过 `ctx.provide('name', value)` 提供服务,另一个则可声明 `inject: ['name']` 来消费它;消费方会在提供方出现前保持等待,提供方卸载后重新回到等待状态。在 `apply` 中注册的一切都会由 cordis_unmount 自动清理。沙箱全局对象:`console`(带 `[cordis:<id>]` 标签,写入 harness 终端)、`harness.defineTool`、`harness.registerTool`、`btoa`、`atob`、`TextEncoder`、`TextDecoder`。Node API 已**禁用**:文件系统/网络/定时工作必须通过 Cordis 服务完成,绝不能使用 Node 内置能力;`require`、`setTimeout`/`setInterval` 和 `fetch` 会抛出重定向错误,`process` 和 `Buffer` 未定义。应改用 inject: ['fs'] + ctx.fs 处理文件、inject: ['web'] + ctx.web 处理 HTTP、inject: ['bash'] + ctx.bash 处理进程、inject: ['timer'] + ctx.setTimeout/ctx.setInterval 处理定时(这些是 fiber effect,卸载时自动清理);cordis_inspect 的 what:"api" 会展示**当前**运行时提供的能力。请编写**纯** JavaScript,不要使用 TypeScript(不得使用 `as` 或类型注解)。注意事项:(1) waterfall(瀑布式事件)事件(例如 tools/pre-execute)会向监听器传入最后一个 `next` 回调,该回调**必须**被调用;不调用 `next()` 就返回会**短路**此次调用。除非你有意拦截,否则请优先使用普通通知事件。(2) 切勿等待只能在当前轮次之后解析的内容;你的代码运行在该轮次的工具调用**内部**,否则会死锁。(3) 你的 `ctx` 是受限门面:可以注册工具、观察事件、提供/消费服务和使用定时器,但不会提供框架内部能力(ctx.root、ctx.fiber、ctx.extend、ctx.plugin 等)。不过,它并非安全边界:你注入的服务(例如 ctx.bash)会访问真实运行时。 ```json { From 523764a58284c04642b88c2d62699248b69982bc Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 21:39:47 +0800 Subject: [PATCH 131/229] fix(runtime): separate packaged JSON-RPC resolution --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 6 +- ...-executable-sdk-runtime-distribution.zh.md | 6 +- packages/boot/app-boot/tests/app-boot.spec.ts | 20 ++++++- .../examples/jsonrpc-demo/README.i18n.yaml | 4 +- packages/examples/jsonrpc-demo/README.md | 2 +- packages/examples/jsonrpc-demo/README.zh.md | 2 +- packages/examples/jsonrpc-demo/package.json | 5 ++ packages/examples/jsonrpc-demo/src/bin.ts | 51 ++--------------- packages/examples/jsonrpc-demo/src/index.ts | 7 ++- .../examples/jsonrpc-demo/src/packaged-bin.ts | 11 ++++ packages/examples/jsonrpc-demo/src/runner.ts | 55 +++++++++++++++++++ .../examples/jsonrpc-demo/tsdown.config.ts | 32 ++++++----- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- .../src/deepseek_harness_runtime/__init__.py | 9 ++- scripts/build-exe-for-python-sdk.ts | 4 +- 18 files changed, 141 insertions(+), 85 deletions(-) create mode 100644 packages/examples/jsonrpc-demo/src/packaged-bin.ts create mode 100644 packages/examples/jsonrpc-demo/src/runner.ts diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index ceb0eae37a..18e4bcf009 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 826194e0d5bd1f0260400c036f8affaf1549629f -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: e4b17a1f3951f36af88564d5365ab7952d6281a5 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 29aa14db2b3b64cefb7960ee8f316682ba1ac736 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: f10d72b394246e9e57033a5382ec977bdc6f3ab8 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 826194e0d5..29aa14db2b 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -34,19 +34,19 @@ Config discovery has two channels and fails loudly when both are missing: the `D ### Plugin resolution: the VFS holds a real package tree, the closure manifest IS the deploy root -Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The JSON-RPC bin supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. Bare specifiers resolve upward along `node_modules` from the Loader's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. +Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local symlink tree and rejecting any remaining manifest gap → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink package payload (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local symlink tree and rejecting any remaining manifest gap → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink package payload (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. [`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with `deepseek-harness-sdk` depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index e4b17a1f39..f10d72b394 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -34,19 +34,19 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 ### 插件解析:VFS 装载真实包树,闭包 manifest(元数据清单)就是部署根目录 -exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。JSON-RPC bin 会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。裸包名从 VFS 内 loader 所在位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 +exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 ### 构建管线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内符号链接树,并拒绝剩余的 manifest 缺口 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的包载荷(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内符号链接树,并拒绝剩余的 manifest 缺口 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的包载荷(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR(Pull Request)添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 [`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 `deepseek-harness-sdk` 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index ab0089fd2c..dc4e92dc4a 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -1,6 +1,7 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' +import { pathToFileURL } from 'node:url' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' @@ -559,9 +560,12 @@ describe('boot', () => { it('can resolve bare plugins from the harness when the config project shadows their package name', async () => { const dir = tmp() + const harness = tmp() const absolutePlugin = join(dir, 'absolute.mjs') const shadow = join(dir, 'node_modules', '@deepseek-ai', 'dsh-system-prompt') + const harnessPlugin = join(harness, 'node_modules', '@deepseek-ai', 'dsh-system-prompt') mkdirSync(shadow, { recursive: true }) + mkdirSync(harnessPlugin, { recursive: true }) writeFileSync(join(shadow, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-system-prompt', type: 'module', @@ -573,6 +577,17 @@ describe('boot', () => { '}', '', ].join('\n')) + writeFileSync(join(harnessPlugin, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh-system-prompt', + type: 'module', + exports: './index.mjs', + })) + writeFileSync(join(harnessPlugin, 'index.mjs'), [ + 'export function apply(ctx) {', + ' ctx.provide("harnessPluginLoaded", true)', + '}', + '', + ].join('\n')) writeFileSync(join(dir, 'relative.mjs'), 'export function apply(ctx) { ctx.provide("relativePluginLoaded", true) }\n') writeFileSync(absolutePlugin, 'export function apply(ctx) { ctx.provide("absolutePluginLoaded", true) }\n') writeFileSync(join(dir, 'cordis.yml'), [ @@ -593,9 +608,10 @@ describe('boot', () => { } finally { await configOwned.fiber.dispose() } - const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, undefined, import.meta.url) + const harnessBaseUrl = pathToFileURL(join(harness, 'entry.mjs')).href + const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, undefined, harnessBaseUrl) try { - expect(ctx.get('systemPrompt')).toBeDefined() + expect(ctx.get('harnessPluginLoaded')).toBe(true) expect(ctx.get('shadowPluginLoaded')).toBeUndefined() expect(ctx.get('relativePluginLoaded')).toBe(true) expect(ctx.get('absolutePluginLoaded')).toBe(true) diff --git a/packages/examples/jsonrpc-demo/README.i18n.yaml b/packages/examples/jsonrpc-demo/README.i18n.yaml index c47938ce3b..ae8f55a8cd 100644 --- a/packages/examples/jsonrpc-demo/README.i18n.yaml +++ b/packages/examples/jsonrpc-demo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/jsonrpc-demo/README.md -README.md: fff8e78698cd3d6320606084ef5c533be7c52633 -README.zh.md: 75382b97ea1837cf1415e8a7f5004206596e168c +README.md: 40ced3ee1fe2d3eac69b82501d417130267d7634 +README.zh.md: 451bdf7428f8265750082af240418d4653bb8595 diff --git a/packages/examples/jsonrpc-demo/README.md b/packages/examples/jsonrpc-demo/README.md index fff8e78698..40ced3ee1f 100644 --- a/packages/examples/jsonrpc-demo/README.md +++ b/packages/examples/jsonrpc-demo/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../scaffold/server/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. The published bin is `dsh-jsonrpc-agent`, and `lib/bin.js` also ships as the `dsh-jsonrpc-agent-pkg` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) used by the Python SDK. +Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../scaffold/server/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. The published `dsh-jsonrpc-agent` bin resolves bare plugins from the configuration project. The Python SDK's `dsh-jsonrpc-agent-pkg` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) uses `lib/packaged-bin.js` instead: packaged bare plugins resolve from its closed runtime tree, while relative plugins remain configuration-relative. ## Config discovery diff --git a/packages/examples/jsonrpc-demo/README.zh.md b/packages/examples/jsonrpc-demo/README.zh.md index 75382b97ea..451bdf7428 100644 --- a/packages/examples/jsonrpc-demo/README.zh.md +++ b/packages/examples/jsonrpc-demo/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../scaffold/server/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。发布的 bin 名为 `dsh-jsonrpc-agent`,`lib/bin.js` 还会作为 Python SDK 使用的 `dsh-jsonrpc-agent-pkg` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)交付。 +只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../scaffold/server/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。发布的 `dsh-jsonrpc-agent` bin 从配置项目解析裸插件。Python SDK 的 `dsh-jsonrpc-agent-pkg` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)改用 `lib/packaged-bin.js`:已打包的裸插件从封闭运行时包树解析,相对插件仍以配置目录为基准。 ## 配置发现 diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index 155660d190..e8605741d1 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -22,6 +22,10 @@ "types": "./lib/types/bin.d.ts", "default": "./lib/bin.js" }, + "./packaged-bin": { + "types": "./lib/types/packaged-bin.d.ts", + "default": "./lib/packaged-bin.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -29,6 +33,7 @@ "lib/index.js", "lib/invariant.js", "lib/bin.js", + "lib/packaged-bin.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/packages/examples/jsonrpc-demo/src/bin.ts b/packages/examples/jsonrpc-demo/src/bin.ts index cecc93dac4..532c1ba1b9 100644 --- a/packages/examples/jsonrpc-demo/src/bin.ts +++ b/packages/examples/jsonrpc-demo/src/bin.ts @@ -1,54 +1,11 @@ #!/usr/bin/env node /** - * Boots an external `cordis.yml`; its `@deepseek-ai/dsh-jsonrpc` entry serves - * newline-delimited JSON-RPC on stdio. `$DSH_CORDIS_CONFIG` wins over `argv[2]`; - * empty or missing paths exit 1, with no default config or `DSH_SNAPSHOT` mode. - * App-boot owns env loading, Loader guards, and settled-tree startup. - * stdin EOF and SIGTERM dispose the root context and exit 0; SIGINT exits 130. - * Protocol `shutdown` belongs to the server plugin. Stdout is reserved for frames. + * Generic JSON-RPC agent bin. External configurations own their bare plugin + * packages; the packaged runtime uses `packaged-bin.ts` instead. * * @module @deepseek-ai/dsh-jsonrpc-demo/bin */ -import { existsSync } from 'node:fs' -import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runJsonrpcAgent } from './runner.ts' -const NAME = 'dsh-jsonrpc-agent' - -/* v8 ignore start -- composition over tested app-boot/jsonrpc and executable acceptance paths */ -installFailLoud(NAME) -loadEnv(NAME) - -// Env wins over argv; empty values are absent. External config defines the deployment. -const fromEnv = process.env['DSH_CORDIS_CONFIG'] -const fromArgv = process.argv[2] -const requested = fromEnv !== undefined && fromEnv !== '' - ? fromEnv - : fromArgv !== undefined && fromArgv !== '' ? fromArgv : undefined -const configPath = requested === undefined ? undefined : resolveConfigPath(requested, undefined) -if (configPath === undefined || !existsSync(configPath)) { - process.stderr.write( - `usage: ${NAME} <path/to/cordis.yml> (or set DSH_CORDIS_CONFIG=<path>, which wins); the config is required — there is no built-in fallback\n`, - ) - process.exit(1) -} - -// The executable owns a closed plugin set; config-adjacent node_modules must -// not shadow the packages embedded beside this bin in the VFS. -const ctx = await boot(NAME, configPath, undefined, undefined, import.meta.url) -let exiting = false - -async function disposeAndExit(code: number): Promise<void> { - if (exiting) return - exiting = true - try { - await ctx.fiber.dispose() - } finally { - process.exit(code) - } -} - -process.stdin.on('end', () => { void disposeAndExit(0) }) -process.on('SIGTERM', () => { void disposeAndExit(0) }) -process.on('SIGINT', () => { void disposeAndExit(130) }) -/* v8 ignore stop */ +await runJsonrpcAgent() diff --git a/packages/examples/jsonrpc-demo/src/index.ts b/packages/examples/jsonrpc-demo/src/index.ts index d4a4017f62..eb85517431 100644 --- a/packages/examples/jsonrpc-demo/src/index.ts +++ b/packages/examples/jsonrpc-demo/src/index.ts @@ -1,7 +1,8 @@ /** - * Bin-only app package: `bin.ts` discovers an external `cordis.yml` and owns - * process exit. This module exports no composition plugin; the config chooses - * whether to load the {@link @deepseek-ai/dsh-jsonrpc} serving plugin. + * Bin-only app package: its generic and packaged entries discover an external + * `cordis.yml` and own process exit. This module exports no composition plugin; + * the config chooses whether to load the + * {@link @deepseek-ai/dsh-jsonrpc} serving plugin. * * @module @deepseek-ai/dsh-jsonrpc-demo */ diff --git a/packages/examples/jsonrpc-demo/src/packaged-bin.ts b/packages/examples/jsonrpc-demo/src/packaged-bin.ts new file mode 100644 index 0000000000..0151b18576 --- /dev/null +++ b/packages/examples/jsonrpc-demo/src/packaged-bin.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node +/** + * Closed-runtime JSON-RPC agent bin. Bare plugins resolve from the installed + * runtime closure while relative plugins remain configuration-relative. + * + * @module @deepseek-ai/dsh-jsonrpc-demo/packaged-bin + */ + +import { runJsonrpcAgent } from './runner.ts' + +await runJsonrpcAgent(import.meta.url) diff --git a/packages/examples/jsonrpc-demo/src/runner.ts b/packages/examples/jsonrpc-demo/src/runner.ts new file mode 100644 index 0000000000..d094707514 --- /dev/null +++ b/packages/examples/jsonrpc-demo/src/runner.ts @@ -0,0 +1,55 @@ +/** + * Shared process lifecycle for the generic and closed-runtime JSON-RPC bins. + * + * @module @deepseek-ai/dsh-jsonrpc-demo/runner + */ + +import { existsSync } from 'node:fs' +import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' + +const NAME = 'dsh-jsonrpc-agent' + +/** + * Boot the explicitly selected external configuration and own process exit. + * @param bareModuleBaseUrl - optional installed-runtime base for bare plugins; + * omit it when the configuration project owns its plugin packages. + * @returns after process handlers are installed; process lifetime then belongs + * to stdin and signal events. + */ +export async function runJsonrpcAgent(bareModuleBaseUrl?: string): Promise<void> { + /* v8 ignore start -- composition over tested app-boot/jsonrpc and executable acceptance paths */ + installFailLoud(NAME) + loadEnv(NAME) + + // Env wins over argv; empty values are absent. External config defines the deployment. + const fromEnv = process.env['DSH_CORDIS_CONFIG'] + const fromArgv = process.argv[2] + const requested = fromEnv !== undefined && fromEnv !== '' + ? fromEnv + : fromArgv !== undefined && fromArgv !== '' ? fromArgv : undefined + const configPath = requested === undefined ? undefined : resolveConfigPath(requested, undefined) + if (configPath === undefined || !existsSync(configPath)) { + process.stderr.write( + `usage: ${NAME} <path/to/cordis.yml> (or set DSH_CORDIS_CONFIG=<path>, which wins); the config is required — there is no built-in fallback\n`, + ) + process.exit(1) + } + + const ctx = await boot(NAME, configPath, undefined, undefined, bareModuleBaseUrl) + let exiting = false + + async function disposeAndExit(code: number): Promise<void> { + if (exiting) return + exiting = true + try { + await ctx.fiber.dispose() + } finally { + process.exit(code) + } + } + + process.stdin.on('end', () => { void disposeAndExit(0) }) + process.on('SIGTERM', () => { void disposeAndExit(0) }) + process.on('SIGINT', () => { void disposeAndExit(130) }) + /* v8 ignore stop */ +} diff --git a/packages/examples/jsonrpc-demo/tsdown.config.ts b/packages/examples/jsonrpc-demo/tsdown.config.ts index a8864a84a9..3609ebbc93 100644 --- a/packages/examples/jsonrpc-demo/tsdown.config.ts +++ b/packages/examples/jsonrpc-demo/tsdown.config.ts @@ -1,15 +1,21 @@ import { defineConfig } from 'tsdown' -/** - * Build the doc-only module and CLI entry; `tsc -b` supplies declarations. - */ -export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, -}) +/** Builds each published entry as a self-contained file admitted by the package whitelist. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/packaged-bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, +]) diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index d06131b7e7..8af8d3e1b9 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md -README.md: 5c7c6f66083a1b56cc6b4aed9565e8b1be014ccc -README.zh.md: cef1478710d20d7faa612e50d0c2f8ec19e8716a +README.md: 912d88938c1a8b3c79dad19ab449c7f912d57e22 +README.zh.md: 5b82f33cfe1413e4fb6ceded04d9b6feca4c94ca diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 5c7c6f6608..912d88938c 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -9,7 +9,7 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: - **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg-<platform>-<arch>` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. -- **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. +- **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index cef1478710..5b82f33cfe 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -9,7 +9,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: - **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg-<platform>-<arch>`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 -- **node(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 +- **node(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index a3aa53ae80..5f3a94b3e3 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -9,7 +9,7 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's needs no Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node - runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`` on a + runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`` on a system Node >= 22.19. It is the current checkout's source build, never selected automatically, and excluded from wheel/sdist distributions. @@ -131,7 +131,12 @@ def _current_platform_tag() -> str: def _node_launch_args() -> tuple[str, str]: node_root = bundled_package_dir() / "runtime" / "node" bin_js = ( - node_root / "node_modules" / "@deepseek-ai" / "dsh-jsonrpc-demo" / "lib" / "bin.js" + node_root + / "node_modules" + / "@deepseek-ai" + / "dsh-jsonrpc-demo" + / "lib" + / "packaged-bin.js" ) if not bin_js.is_file(): raise FileNotFoundError( diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 5f525ec358..34ecdda1c0 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -16,8 +16,8 @@ const root = resolve(import.meta.dirname, '..') /** The closure manifest whose dependencies define the executable. */ const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg' -/** The app entry inside the deployed closure. */ -const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js' +/** The closed-runtime app entry inside the deployed closure. */ +const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js' const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' /** Default Node major; SEA mode requires at least Node 22. */ const DEFAULT_NODE_RANGE = 'node24' 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 132/229] 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 ( <span className={css.label} title={text?.description ?? t('headerHint')}> - <IconAgentPresetOutline16 className={css.icon} /> + <IconAgentPresetOutline16 size={14} className={css.icon} /> {text?.name ?? preset} </span> ) 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 a63fe01ee40ed8e350c0d0f5b20ddd5501d6d6d1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 21:41:40 +0800 Subject: [PATCH 133/229] fix(packaging): allow packaged JSON-RPC bin --- scripts/check-workspace-constraints.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 5f6770b2e1..8208f95cd2 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -126,6 +126,9 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = { '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], + // The Python runtime uses a distinct closed-resolution bin; the public CLI + // keeps config-owned bare-package resolution through lib/bin.js. + '@deepseek-ai/dsh-jsonrpc-demo': ['lib/packaged-bin.js'], // The argv-prefix runner entry ships beside the lib as its own bundle; // sandbox-local resolves it through the package's ./runner export. tsdown // also shares its generated FFI code through a hashed runtime chunk. From 8d838fc71ac928c2874a957b2c0a8fb849b822cb Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 21:46:04 +0800 Subject: [PATCH 134/229] test(runtime): bound JSON-RPC entry coverage --- packages/examples/jsonrpc-demo/src/packaged-bin.ts | 1 + packages/examples/jsonrpc-demo/src/runner.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/examples/jsonrpc-demo/src/packaged-bin.ts b/packages/examples/jsonrpc-demo/src/packaged-bin.ts index 0151b18576..4ad41a2ee4 100644 --- a/packages/examples/jsonrpc-demo/src/packaged-bin.ts +++ b/packages/examples/jsonrpc-demo/src/packaged-bin.ts @@ -8,4 +8,5 @@ import { runJsonrpcAgent } from './runner.ts' +/* v8 ignore next -- exercised through the built Python runtime carriers */ await runJsonrpcAgent(import.meta.url) diff --git a/packages/examples/jsonrpc-demo/src/runner.ts b/packages/examples/jsonrpc-demo/src/runner.ts index d094707514..25d17481e5 100644 --- a/packages/examples/jsonrpc-demo/src/runner.ts +++ b/packages/examples/jsonrpc-demo/src/runner.ts @@ -7,6 +7,7 @@ import { existsSync } from 'node:fs' import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +/* v8 ignore start -- composition over tested app-boot/jsonrpc and executable acceptance paths */ const NAME = 'dsh-jsonrpc-agent' /** @@ -17,7 +18,6 @@ const NAME = 'dsh-jsonrpc-agent' * to stdin and signal events. */ export async function runJsonrpcAgent(bareModuleBaseUrl?: string): Promise<void> { - /* v8 ignore start -- composition over tested app-boot/jsonrpc and executable acceptance paths */ installFailLoud(NAME) loadEnv(NAME) @@ -51,5 +51,5 @@ export async function runJsonrpcAgent(bareModuleBaseUrl?: string): Promise<void> process.stdin.on('end', () => { void disposeAndExit(0) }) process.on('SIGTERM', () => { void disposeAndExit(0) }) process.on('SIGINT', () => { void disposeAndExit(130) }) - /* v8 ignore stop */ } +/* v8 ignore stop */ 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 135/229] 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 136/229] 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 ec810b02e8872ff14da7a058f10021e53585fe16 Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Mon, 10 Aug 2026 22:00:58 +0800 Subject: [PATCH 137/229] test(subagent): dispose continuable inheritance fixtures --- .../subagent/subagent/tests/continuation-inheritance.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index e30623c073..e119ffec13 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -28,13 +28,16 @@ import SubagentService from '../src/index.ts' type Script = ConstructorParameters<typeof MockAdapter>[0] const roots: string[] = [] -afterEach(() => { +const contexts: Context[] = [] +afterEach(async () => { + for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose() for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) /** Boot the continuable stack plus both policy services the manager consumes opportunistically. */ async function setup(script: Script) { const ctx = new Context() + contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) const root = mkdtempSync(join(tmpdir(), 'dsh-continuation-inherit-')) roots.push(root) From 194828e8b8ac7ad347e43f80d1d88ea022d47ae6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:50:09 +0800 Subject: [PATCH 138/229] build(vendor): add the @deepseek-ai rescope codemod, its mapping doc, and its Agent Note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every harness package declares cordis as a peer dependency, so publishing the harness publishes the vendored framework layer too; under the upstream names that publication would squat them on the registry. scripts/rescope-vendor.ts owns the rename: the nine-package mapping, a delimited-token rule that leaves cordis.yml, the Loader's cordis: builtins and vendor directory names alone, per-file exemptions where a name is a directory or an upstream runtime identifier, and the exact edits for sites a token rule cannot express — dot-notation lookups, unquoted manifest keys, a regex literal whose failure would make every Context-merge scan silently find nothing, the vendored-manifest table, and the contracts that told readers vendored packages keep their upstream names. Markdown follows the rename inside every fence, because a fence is code a reader copies or configuration they mount, and in `docs/` prose as well, where a sentence quoting a name teaches something this repository no longer resolves. Prose elsewhere records what was true when it was written, and the same spelling can mean something else: the Python SDK's `cordis` option, or the unvendored `@cordisjs/plugin-http`. `docs/rescope.md` states both names on purpose and is exempt. exactEditState() classifies every exact edit as pending, applied, or invalid. An insertion keeps its anchor and a deletion keeps its remainder, so each side counts the form that survives: a duplicated insertion, a half-applied replacement, and a deletion whose remainder moved are all invalid. The run classifies every edit before writing anything and aborts on the first invalid one, so a disagreement between the mapping and the tree cannot leave a half-rescoped checkout; each write re-reads its file, because two edits can target one. rescope-vendor.spec.ts pins those rejections, and --check asserts the whole post-state from the hygiene gate, so CI owns the invariant. --reverse restores the upstream names, verified as a round trip: reverse, then apply, reproduces this tree byte for byte. docs/rescope.md is the consumer-facing reference: the old-name/new-name table with each package's role, what the rename deliberately leaves alone, the sites callers must change, and the commands to apply, verify, and revert. The Agent Note carries the decision and its consequences. The rename itself lands in the next commit, produced by running the script. --- ...026-08-10-vendor-package-rescope.i18n.yaml | 6 + .../2026-08-10-vendor-package-rescope.md | 51 ++ .../2026-08-10-vendor-package-rescope.zh.md | 51 ++ docs/rescope.i18n.yaml | 6 + docs/rescope.md | 53 ++ docs/rescope.zh.md | 53 ++ package.json | 4 +- scripts/rescope-vendor.spec.ts | 41 + scripts/rescope-vendor.ts | 771 ++++++++++++++++++ scripts/run-gates.ts | 1 + 10 files changed, 1036 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/process/2026-08-10-vendor-package-rescope.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md create mode 100644 .agents/notes/implemented/process/2026-08-10-vendor-package-rescope.zh.md create mode 100644 docs/rescope.i18n.yaml create mode 100644 docs/rescope.md create mode 100644 docs/rescope.zh.md create mode 100644 scripts/rescope-vendor.spec.ts create mode 100644 scripts/rescope-vendor.ts diff --git a/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.i18n.yaml new file mode 100644 index 0000000000..46715efdcb --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md +2026-08-10-vendor-package-rescope.md: f2a142cec4e3c28fae54af8063cd730931fa738b +2026-08-10-vendor-package-rescope.zh.md: 994064fc869e1ccd95609ec67010eae794ed1c48 diff --git a/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md new file mode 100644 index 0000000000..f2a142cec4 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md @@ -0,0 +1,51 @@ +# Agent Note: Rescope vendored Cordis into @deepseek-ai + +Status: implemented + +English | [中文](2026-08-10-vendor-package-rescope.zh.md) + +## Problem + +The nine packages under `vendor/` kept their upstream npm names (`cordis`, `cosmokit`, `schemastery`, `@cordisjs/plugin-*`). That premise does not survive publication: every harness package declares `cordis` as a peer dependency, so a consumer installing `@deepseek-ai/dsh-*` must resolve it from the registry, which means publishing the harness publishes this framework layer too. Publishing it under the upstream names squats them on the registry, and where that registry proxies npmjs, the same-name entries shadow the real upstream packages and install the wrong framework into unrelated projects. + +## Decision + +All nine packages move into the `@deepseek-ai` scope. Directory names, upstream version numbers, and dependency ranges stay untouched, so the `vendor/README.md` manifest still reads as an upstream snapshot. [docs/rescope.md](../../../../docs/rescope.md) restates this mapping for consumers. + +| Directory | npm name | Upstream name | +|---|---|---| +| `cordis/` | `@deepseek-ai/cordis` | `cordis` | +| `cosmokit/` | `@deepseek-ai/cosmokit` | `cosmokit` | +| `schemastery/` | `@deepseek-ai/schemastery` | `schemastery` | +| `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | +| `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | +| `group/` | `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | +| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | +| `hmr/` | `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | +| `logger-console/` | `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | + +The rewrite touches only **delimited, complete package-name tokens**: quoted or backticked specifiers (optionally with a `/subpath`), `package.json` names and dependency keys, `cordis.yml` `name:` values, and `tsconfig.base.json` `paths` keys. Identically spelled strings that are not package names therefore stayed as they were: the `cordis.yml` config-file family, the Loader's literal `cordis:` builtin prefix (`cordis:include`, `cordis:group` — see `vendor/loader/src/config/tree.ts`), kind strings like `cordis-config-entry`, `@deepseek-ai/dsh-tool-cordis`, Schemastery's upstream `Symbol.for('schemastery')` and `vendor:` metadata field, the `packages/<group>/` directory names in `GROUP_ORDER` (`scripts/gen-module-graph.ts`, `scripts/gen-doc-graphs.ts`), and the upstream install instructions in `vendor/*/README.md`. + +Two classes are invisible to a token rule and were renamed site by site. First, property access and unquoted object keys — `manifest.peerDependencies?.cordis`, and the manifest keys the scaffold generates in `npm-dependency-policy.ts` and `local-plugin-blueprint.ts` — where TypeScript cannot catch a stale `Record<string, string>` key. Second, constants that carry the name as data: the vendored set in `check-workspace-constraints.ts`, the group/include names in `verify-cordis-config.ts`, the `declare module` target strings in `cordis-walk.ts`, `gen-scoped-events.ts`, and typert's `analyzer.ts`, and `alwaysBundle` in `app-boot/tsdown.config.ts`. + +Markdown splits along what a reader does with it. Every fence follows the rename regardless of its info string, because a fence is code they copy or configuration they mount — the `yaml` fences naming Loader plugins and the `ts ignore-check` fences beside compiled ones included. Prose follows it under `docs/`, where a tutorial sentence quoting a name teaches something this repository no longer resolves. Prose elsewhere — `vendor/*/README.md`, package READMEs, and `.agents/notes/` — keeps the names it was written with, both because it records what was true then and because the same spelling can mean something else: the Python SDK's `cordis` option, the unvendored `@cordisjs/plugin-http`, or an agent-preset id. + +## Consequences + +- No upstream name remains in the publication set. `publish-npm-baseline.ts` now requires every published package to be `@deepseek-ai/*` with no vendored exemption, so regressing the rename fails before packing. +- The `vendor/README.md` manifest table gains an upstream-name column; `gen-third-party-notices` parses six columns and renders that name into `THIRD_PARTY_NOTICES.md`, keeping MIT attribution pointed at each fork's origin rather than our scope. +- `pnpm-workspace.yaml` drops the `cordis` and `@cordisjs/plugin-loader` `minimumReleaseAgeExclude` entries, which can no longer be fetched from a registry, and `knip.json` drops the `@cordisjs/.+` ignore pattern that `@deepseek-ai/.+` already covers. +- Upstream sync follows the procedure in `vendor/README.md` with one added obligation in step 3: re-apply the rename over the copied sources with `pnpm run rescope-vendor --apply`, whose mapping and the table's two name columns must agree. +- **Returning to the official upstream packages** means applying that mapping in reverse — `pnpm run rescope-vendor --apply --reverse` — then restoring the two `minimumReleaseAgeExclude` entries and relaxing the publication-set assertion. It spans roughly 1300 files, so replay it with the script rather than by hand. + +`scripts/rescope-vendor.ts` owns the rename: the mapping, the delimited-token rule, the per-file exemptions where a name is a directory instead of a package, the exact edits above, and a `--check` mode asserting no residue, every exact edit landed, and idempotency, which the `hygiene` gate runs on every CI pass. A rebase replays it instead of resolving a 1300-file conflict, and an upstream change to one of the pinned sites fails the run loudly instead of being silently skipped. + +## Alternatives considered + +**Keep the upstream names and exclude `vendor/` from publication.** Rejected because every harness package declares `cordis` as a peer dependency, so an installed `@deepseek-ai/dsh-*` would have no resolvable framework. + +**Rename only at pack time.** Rejected because the published names would disagree with the source tree, every module specifier would have to be rewritten inside the publish path, and no local run could reproduce what was published. + +**Rename the `vendor/` directories and unify versions on the repository base version too.** Rejected because directory names are not publication identity — renaming them drags in project references, tsdown globs, and documentation paths for no gain — and a `0.0.1` version would no longer satisfy the preserved `^4.0.0-rc.7` ranges, so pnpm would look for a registry copy and `verify-vendored-links` would fail. + +**Rewrite prose outside `docs/` and historical Agent Notes as well.** Rejected because those record what was true when written, and a bare `cordis` there is as likely to be an SDK option name or a preset id as a package; `docs/rescope.md` carries the mapping for readers instead. diff --git a/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.zh.md b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.zh.md new file mode 100644 index 0000000000..994064fc86 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 把 vendored Cordis 重命名进 @deepseek-ai scope + +Status: implemented + +[English](2026-08-10-vendor-package-rescope.md) | 中文 + +## 问题 + +`vendor/` 下的九个包此前保留上游 npm 名(`cordis`、`cosmokit`、`schemastery`、`@cordisjs/plugin-*`)。这个前提在发布时不成立:每个 harness 包都把 `cordis` 声明成 peer dependency,装了 `@deepseek-ai/dsh-*` 的消费者必须能从 registry 解析到它,所以发布 harness 必然连带发布这一层框架。用上游名发布就是在 registry 上占用别人的名字;若该 registry 对 npmjs 做上游代理,本名条目还会遮蔽真正的上游包,把错误的框架装进无关项目。 + +## 决定 + +九个包统一改名进 `@deepseek-ai` scope。目录名、上游版本号、依赖 range 一律不动,所以 `vendor/README.md` 的清单仍然读作一份上游快照。面向使用者的映射表见 [docs/rescope.md](../../../../docs/rescope.md)。 + +| 目录 | npm 名 | 上游名 | +|---|---|---| +| `cordis/` | `@deepseek-ai/cordis` | `cordis` | +| `cosmokit/` | `@deepseek-ai/cosmokit` | `cosmokit` | +| `schemastery/` | `@deepseek-ai/schemastery` | `schemastery` | +| `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | +| `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | +| `group/` | `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | +| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | +| `hmr/` | `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | +| `logger-console/` | `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | + +改写只落在**带定界符的完整包名 token** 上:引号或反引号包裹的 specifier(可带 `/子路径`)、`package.json` 的 `name` 与依赖键、`cordis.yml` 的 `name:` 值、`tsconfig.base.json` 的 `paths` 键。因此以下同形串一律未改,它们不是包名:`cordis.yml` 及其家族文件名、Loader 的 `cordis:` 内建前缀(`cordis:include`、`cordis:group`,见 `vendor/loader/src/config/tree.ts`)、`cordis-config-entry` 这类 kind 串、`@deepseek-ai/dsh-tool-cordis`、Schemastery 上游的 `Symbol.for('schemastery')` 与 `vendor:` 元数据、`scripts/gen-module-graph.ts` 与 `gen-doc-graphs.ts` 里 `GROUP_ORDER` 的 `packages/<group>/` 目录名,以及 `vendor/*/README.md` 里的上游安装指引。 + +Token 规则看不见两类点位,它们按名字逐处改:一是属性访问与未加引号的对象键(`manifest.peerDependencies?.cordis`、脚手架 `npm-dependency-policy.ts` 与 `local-plugin-blueprint.ts` 生成的清单键)——TypeScript 抓不到过期的 `Record<string, string>` 键;二是把名字当数据的常量(`check-workspace-constraints.ts` 的 vendored 集合、`verify-cordis-config.ts` 的 group/include 名、`cordis-walk.ts` 与 `gen-scoped-events.ts` 与 typert `analyzer.ts` 里识别 `declare module` 目标的字符串、`app-boot/tsdown.config.ts` 的 `alwaysBundle`)。 + +Markdown 按「读者拿它做什么」一分为二。围栏一律跟着改,不看 info string——围栏里是读者要照抄的代码或要挂载的配置,包括写着 Loader 插件名的 `yaml` 围栏和紧邻编译围栏的 `ts ignore-check` 围栏。散文只在 `docs/` 下跟着改:教程里引用某个名字的句子,教的是本仓已不解析的东西。`docs/` 之外的散文——`vendor/*/README.md`、各包 README、`.agents/notes/`——保留写作当时的名字:既因为它记录的是当时的事实,也因为同一个拼写可能指别的东西,比如 Python SDK 的 `cordis` 选项、我们没 vendor 的 `@cordisjs/plugin-http`,或某个 agent-preset 的 id。 + +## 影响 + +- 发布集里不再有任何上游名:`publish-npm-baseline.ts` 现在无条件要求每个待发包都是 `@deepseek-ai/*`,vendored 包不再豁免,改名一旦回退就会在打包前失败。 +- `vendor/README.md` 的清单表新增「上游名」列,`gen-third-party-notices` 随之解析六列并把上游名渲进 `THIRD_PARTY_NOTICES.md`;MIT 归属指向 fork 的来源,而不是我们的 scope。 +- `pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude` 删去 `cordis` 与 `@cordisjs/plugin-loader` 两条:改名后这两个名字永远不从 registry 取。`knip.json` 的 `@cordisjs/.+` 忽略模式同理删除,已被 `@deepseek-ai/.+` 覆盖。 +- 上游 sync 照 `vendor/README.md` 的流程走,第 3 步多一项:对拷进来的源码重跑 `pnpm run rescope-vendor --apply`,脚本里的映射与清单表两列名字必须一致。 +- **要回到官方上游包**时反着跑这份映射——`pnpm run rescope-vendor --apply --reverse`——再补回 `minimumReleaseAgeExclude` 两条、放开发布集对 `@deepseek-ai/*` 的断言。改写量约 1300 个文件,用脚本重放而不是手改。 + +改名这件事由 `scripts/rescope-vendor.ts` 承载:映射、带定界符的 token 规则、名字其实是目录而非包时的逐文件豁免、上面那批精确改写,以及一个断言「零残留、每条精确改写都落上、幂等」的 `--check` 模式——它由 `hygiene` 门在每次 CI 上执行。rebase 时重放它,而不是去解一个 1300 文件的冲突;上游动了任一被钉住的点位,脚本会响亮失败而不是静默漏改。 + +## 考虑过的替代方案 + +**保留上游名,把 `vendor/` 排除在发布集之外。** 否决:每个 harness 包都声明 `cordis` 为 peer dependency,装好的 `@deepseek-ai/dsh-*` 会解析不到框架。 + +**只在打包时改名。** 否决:发出去的名字与源码树不一致,所有模块 specifier 得在发布路径里现改,本地也没有任何一次运行能复现发布出去的东西。 + +**目录名与版本号一并改。** 否决:目录名不是发布标识,改它会连带项目引用、tsdown glob 与文档路径,收益为零;版本号并入 `0.0.1` 后不再满足保留下来的 `^4.0.0-rc.7` range,pnpm 会转去 registry 找副本,`verify-vendored-links` 直接红。 + +**`docs/` 之外的散文与历史 Agent Note 一起改。** 否决:它们记录的是写作当时的事实,而且那里的裸 `cordis` 同样可能是 SDK 选项名或某个 preset id,未必是包;面向读者的映射由 `docs/rescope.md` 承载。 diff --git a/docs/rescope.i18n.yaml b/docs/rescope.i18n.yaml new file mode 100644 index 0000000000..2959c354da --- /dev/null +++ b/docs/rescope.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/rescope.md +rescope.md: 3dde39875021e7a4161e1ae66550e9dedf5eb4fa +rescope.zh.md: a7f355cf651fb063bf2d4c3cefe18babd4a57401 diff --git a/docs/rescope.md b/docs/rescope.md new file mode 100644 index 0000000000..3dde398750 --- /dev/null +++ b/docs/rescope.md @@ -0,0 +1,53 @@ +# Vendored package rescope + +English | [中文](rescope.zh.md) + +The Cordis framework and its foundation libraries are vendored under [`vendor/`](../vendor/README.md) and published under the `@deepseek-ai` scope, because every harness package declares the framework as a peer dependency: publishing the harness publishes this layer with it, and under the upstream names that publication would squat them on the registry. This page is the name mapping; the decision and its consequences live in the [rescope Agent Note](../.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md), and the upstream commits in [`vendor/README.md`](../vendor/README.md). + +## Name mapping + +| Directory | Upstream name | Published name | Version | Role | +|---|---|---|---|---| +| `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.7 | Framework core: `Context`, `Service`, `Fiber`, events | +| `vendor/cosmokit/` | `cosmokit` | `@deepseek-ai/cosmokit` | 1.8.1 | Shared utilities the framework and Schemastery build on | +| `vendor/schemastery/` | `schemastery` | `@deepseek-ai/schemastery` | 3.18.0 | Config schemas (`Schema`) behind every plugin's `Config` | +| `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | `cordis.yml` loading, plugin resolution, repository cache | +| `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.0.4 | Config includes and patch overlays | +| `vendor/group/` | `@cordisjs/plugin-group` | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | Nested plugin groups | +| `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.2 | Disposal-aware timers on `ctx` | +| `vendor/hmr/` | `@cordisjs/plugin-hmr` | `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | Hot module replacement for plugins and config | +| `vendor/logger-console/` | `@cordisjs/plugin-logger-console` | `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | Console logger exporter | + +Subpath exports keep their path: `@cordisjs/plugin-loader/repository` becomes `@deepseek-ai/cordis-plugin-loader/repository`. + +## What the rename does not touch + +- **Directory names and versions.** `vendor/hmr/` stays `vendor/hmr/`, and every package keeps the upstream version its manifest table row records, so the vendored tree still reads as an upstream snapshot. +- **Dependency ranges.** A dependency entry changes its key, never its range: `"cordis": "^4.0.0-rc.7"` becomes `"@deepseek-ai/cordis": "^4.0.0-rc.7"`. `linkWorkspacePackages` resolves those preserved ranges to the pinned workspaces. +- **The Loader's `cordis:` builtin prefix.** `cordis:include` and `cordis:group` are a protocol prefix, not a package name. +- **The `cordis.yml` configuration family**, including `*.cordis.yml`, `*.cordis.snapshot.yml`, and `cordis.patch.yml`. +- **Harness packages whose own names contain the word**, such as `@deepseek-ai/dsh-tool-cordis`. +- **Upstream runtime identifiers**, such as Schemastery's `Symbol.for('schemastery')` and its `vendor:` metadata field. +- **Prose outside `docs/`.** `vendor/*/README.md`, package READMEs, and Agent Notes keep the names they were written with; a bare `cordis` there can also be the Python SDK's option name or an agent-preset id. Inside `docs/`, prose and every Markdown fence follow the rename. + +## What your code has to change + +| Site | Before | After | +|---|---|---| +| Module import | `import { Context } from 'cordis'` | `import { Context } from '@deepseek-ai/cordis'` | +| Typed-event merge | `declare module 'cordis'` | `declare module '@deepseek-ai/cordis'` | +| `package.json` dependency key | `"@cordisjs/plugin-hmr": "^1.0.15"` | `"@deepseek-ai/cordis-plugin-hmr": "^1.0.15"` | +| `cordis.yml` plugin entry | `name: '@cordisjs/plugin-include'` | `name: '@deepseek-ai/cordis-plugin-include'` | + +## Applying, verifying, and reverting + +[`scripts/rescope-vendor.ts`](../scripts/rescope-vendor.ts) owns the mapping above and performs the rename, so no reference is renamed by hand: + +```sh +pnpm run rescope-vendor # report what would change +pnpm run rescope-vendor --apply # rewrite every reference +pnpm run rescope-vendor:check # assert the post-state; runs in the hygiene gate +pnpm run rescope-vendor --apply --reverse # return to the upstream names +``` + +Re-apply it after an upstream sync ([procedure](../vendor/README.md)), and follow it with the regeneration it prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`, and `pnpm run verify-translation-pairing --write` for the bilingual pairs it touched. diff --git a/docs/rescope.zh.md b/docs/rescope.zh.md new file mode 100644 index 0000000000..a7f355cf65 --- /dev/null +++ b/docs/rescope.zh.md @@ -0,0 +1,53 @@ +# Vendored 包改名 + +[English](rescope.md) | 中文 + +Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/README.md) 下,并以 `@deepseek-ai` scope 发布:每个 harness 包都把框架声明为 peer dependency,发布 harness 就会连带发布这一层,用上游名发布等于在 registry 上占用别人的名字。本页是名字映射表;决策与影响见 [改名 Agent Note](../.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md),上游 commit 见 [`vendor/README.md`](../vendor/README.md)。 + +## 名字映射 + +| 目录 | 上游名 | 发布名 | 版本 | 角色 | +|---|---|---|---|---| +| `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.7 | 框架核心:`Context`、`Service`、`Fiber`、事件 | +| `vendor/cosmokit/` | `cosmokit` | `@deepseek-ai/cosmokit` | 1.8.1 | 框架与 Schemastery 共用的基础工具 | +| `vendor/schemastery/` | `schemastery` | `@deepseek-ai/schemastery` | 3.18.0 | 配置 schema(`Schema`),每个插件的 `Config` 都基于它 | +| `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | `cordis.yml` 装载、插件解析、repository 缓存 | +| `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.0.4 | 配置包含与 patch 叠加 | +| `vendor/group/` | `@cordisjs/plugin-group` | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 嵌套插件分组 | +| `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.2 | `ctx` 上随 disposal 回收的定时器 | +| `vendor/hmr/` | `@cordisjs/plugin-hmr` | `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | 插件与配置的热替换 | +| `vendor/logger-console/` | `@cordisjs/plugin-logger-console` | `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | 控制台日志导出 | + +子路径导出保持原路径:`@cordisjs/plugin-loader/repository` 变成 `@deepseek-ai/cordis-plugin-loader/repository`。 + +## 改名不碰什么 + +- **目录名与版本号。** `vendor/hmr/` 仍是 `vendor/hmr/`,每个包保留清单表那行记录的上游版本,所以 vendored 树依旧读作一份上游快照。 +- **依赖 range。** 依赖条目只换键、不换范围:`"cordis": "^4.0.0-rc.7"` 变成 `"@deepseek-ai/cordis": "^4.0.0-rc.7"`;`linkWorkspacePackages` 靠这些保留下来的范围把它们解析到固定的 workspace。 +- **Loader 的 `cordis:` 内建前缀。** `cordis:include`、`cordis:group` 是协议前缀,不是包名。 +- **`cordis.yml` 配置文件家族**,包括 `*.cordis.yml`、`*.cordis.snapshot.yml`、`cordis.patch.yml`。 +- **名字里带这个词的 harness 包**,例如 `@deepseek-ai/dsh-tool-cordis`。 +- **上游运行时标识符**,例如 Schemastery 的 `Symbol.for('schemastery')` 及其 `vendor:` 元数据字段。 +- **`docs/` 之外的散文。** `vendor/*/README.md`、各包 README 与 Agent Note 保留写作当时的名字;那里的裸 `cordis` 也可能是 Python SDK 的选项名或某个 agent-preset 的 id。`docs/` 之内,散文与所有 Markdown 围栏都跟着改。 + +## 你的代码要改什么 + +| 位置 | 改前 | 改后 | +|---|---|---| +| 模块 import | `import { Context } from 'cordis'` | `import { Context } from '@deepseek-ai/cordis'` | +| 类型事件声明合并 | `declare module 'cordis'` | `declare module '@deepseek-ai/cordis'` | +| `package.json` 依赖键 | `"@cordisjs/plugin-hmr": "^1.0.15"` | `"@deepseek-ai/cordis-plugin-hmr": "^1.0.15"` | +| `cordis.yml` 插件条目 | `name: '@cordisjs/plugin-include'` | `name: '@deepseek-ai/cordis-plugin-include'` | + +## 施加、核验与回退 + +上面这份映射由 [`scripts/rescope-vendor.ts`](../scripts/rescope-vendor.ts) 承载并执行改名,任何引用都不靠手改: + +```sh +pnpm run rescope-vendor # report what would change +pnpm run rescope-vendor --apply # rewrite every reference +pnpm run rescope-vendor:check # assert the post-state; runs in the hygiene gate +pnpm run rescope-vendor --apply --reverse # return to the upstream names +``` + +上游 sync 之后重跑它([流程](../vendor/README.md)),并接上它打印的重生成:`pnpm install` 重生成 lockfile、`pnpm run gen-third-party-notices`、以及对它触及的双语对跑 `pnpm run verify-translation-pairing --write`。 diff --git a/package.json b/package.json index 2830640c26..5b07ed60fa 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,8 @@ "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", + "rescope-vendor": "tsx scripts/rescope-vendor.ts", + "rescope-vendor:check": "tsx scripts/rescope-vendor.ts --check", "verify-client-domain-graph": "tsx scripts/verify-client-domain-graph.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", @@ -117,7 +119,7 @@ "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", - "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", + "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", "demo:headless": "node --import tsx/esm apps/cli/src/bin.ts run", diff --git a/scripts/rescope-vendor.spec.ts b/scripts/rescope-vendor.spec.ts new file mode 100644 index 0000000000..563be68dfa --- /dev/null +++ b/scripts/rescope-vendor.spec.ts @@ -0,0 +1,41 @@ +/** + * Acceptance-path coverage for the rescope codemod's exact-edit classifier: a + * duplicated insertion — what a non-idempotent apply produces — must be + * rejected rather than applied again. + */ + +import { describe, expect, it } from 'vitest' +import { exactEditState } from './rescope-vendor.ts' + +const ANCHOR = '\n## Sync procedure' +const INSERTED = `\n15. **rescope**: one log entry.\n${ANCHOR}` + +describe('exactEditState', () => { + it('classifies an insertion by its target form, so a duplicate is invalid', () => { + expect(exactEditState(`log\n${ANCHOR}\n`, ANCHOR, INSERTED, 1)).toBe('pending') + expect(exactEditState(`log${INSERTED}\n`, ANCHOR, INSERTED, 1)).toBe('applied') + // The anchor survives an insertion, so counting the source form would have + // called this pending and inserted the entry a second time. + expect(exactEditState(`log${INSERTED}${INSERTED}\n`, ANCHOR, INSERTED, 1)).toBe('invalid') + expect(exactEditState('log\n', ANCHOR, INSERTED, 1)).toBe('invalid') + }) + + it('classifies a deletion by its source form, and requires its remainder to survive', () => { + const remainder = 'exclude:\n' + const withEntries = 'exclude:\n - cordis@4\n' + expect(exactEditState(withEntries, withEntries, remainder, 1)).toBe('pending') + expect(exactEditState(remainder, withEntries, remainder, 1)).toBe('applied') + // Upstream dropped the whole field: the source form is gone, but so is the + // remainder, so this is a moved site rather than a completed deletion. + expect(exactEditState('unrelated:\n', withEntries, remainder, 1)).toBe('invalid') + }) + + it('requires a replacement to leave no source form and the exact target count', () => { + expect(exactEditState('a = 1\n', 'a = 1', 'b = 2', 1)).toBe('pending') + expect(exactEditState('b = 2\n', 'a = 1', 'b = 2', 1)).toBe('applied') + expect(exactEditState('b = 2\nb = 2\n', 'a = 1', 'b = 2', 1)).toBe('invalid') + // A moved or partially applied site: neither state is complete. + expect(exactEditState('a = 1\nb = 2\n', 'a = 1', 'b = 2', 1)).toBe('invalid') + expect(exactEditState('x\n', 'a = 1', 'b = 2', 1)).toBe('invalid') + }) +}) diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts new file mode 100644 index 0000000000..e083d375e3 --- /dev/null +++ b/scripts/rescope-vendor.ts @@ -0,0 +1,771 @@ +/** + * Rescope the vendored Cordis packages into the `@deepseek-ai` scope, and undo + * that rescope with `--reverse`. Every harness package declares `cordis` as a + * peer dependency, so publication carries this framework layer too; publishing + * it under the upstream names would squat them on the registry + * ([rationale](../.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md), + * [name mapping](../docs/rescope.md)). + * + * The generic pass rewrites ONLY delimited, complete package-name tokens: + * `'old'` / `"old"` / `` `old` `` / `'old/subpath'`, plus a YAML `name: old` + * scalar. A match needs a quote (or `name: `) immediately left and the matching + * quote — optionally after a `/subpath` — immediately right, which excludes + * `cordis.yml`, the Loader's `cordis:` builtin prefix, `cordis-config-entry`, + * `@deepseek-ai/dsh-tool-cordis`, and `cordiverse/cordis`, and makes the + * rewrite idempotent because the scoped name's `cordis` is preceded by `/`. + * Markdown follows the rename inside every fence, and in `docs/` prose too: + * a tutorial that teaches an unresolvable name is wrong, while prose elsewhere + * records what was true when it was written. + * + * Sites the token rule cannot express (dot-notation access, unquoted object + * keys, regex literals, the vendored-manifest table) are listed in + * {@link EXACT_EDITS} with an exact hit count, so an upstream change to one of + * them fails loudly instead of being silently skipped. + * + * Usage: `pnpm run rescope-vendor [--apply|--check] [--reverse]`. Without a + * mode it reports what would change. `--check` asserts the post-state: no + * residue, every exact edit landed, every postcondition holds, and a second + * `--apply` would be a no-op. + */ + +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(import.meta.dirname, '..') + +/** One vendored package's directory, upstream npm name, and rescoped name. */ +interface Rename { + readonly directory: string + readonly upstream: string + readonly scoped: string +} + +/** The mapping this codemod applies; `vendor/README.md` carries the same table. */ +const RENAMES: readonly Rename[] = [ + { directory: 'cordis', upstream: 'cordis', scoped: '@deepseek-ai/cordis' }, + { directory: 'cosmokit', upstream: 'cosmokit', scoped: '@deepseek-ai/cosmokit' }, + { directory: 'schemastery', upstream: 'schemastery', scoped: '@deepseek-ai/schemastery' }, + { directory: 'loader', upstream: '@cordisjs/plugin-loader', scoped: '@deepseek-ai/cordis-plugin-loader' }, + { directory: 'include', upstream: '@cordisjs/plugin-include', scoped: '@deepseek-ai/cordis-plugin-include' }, + { directory: 'group', upstream: '@cordisjs/plugin-group', scoped: '@deepseek-ai/cordis-plugin-group' }, + { directory: 'timer', upstream: '@cordisjs/plugin-timer', scoped: '@deepseek-ai/cordis-plugin-timer' }, + { directory: 'hmr', upstream: '@cordisjs/plugin-hmr', scoped: '@deepseek-ai/cordis-plugin-hmr' }, + { directory: 'logger-console', upstream: '@cordisjs/plugin-logger-console', scoped: '@deepseek-ai/cordis-plugin-logger-console' }, +] + +const EXTENSIONS = ['.ts', '.tsx', '.js', '.mjs', '.cjs', '.tpl', '.json', '.yml', '.yaml', '.md'] as const + +/** An exact-string edit the token rule cannot express, with its required hit count. */ +interface ExactEdit { + readonly id: string + readonly file: string + readonly find: string + readonly replace: string + readonly expect: number +} + +/** + * A file where an upstream name also appears as a vendor DIRECTORY name or an + * upstream runtime identifier: the generic pass is disabled for the listed + * names and {@link EXACT_EDITS} renames the real package-name occurrences. + */ +interface GenericSkip { + readonly file: string + readonly upstream: readonly string[] +} + +const GENERIC_SKIPS: readonly GenericSkip[] = [ + // `vendorPackages` lists vendor/ directory names, joined with 'vendor' below it. + { file: 'packages/examples/acp-demo/tests/built-bin.e2e.ts', upstream: ['cordis', 'cosmokit', 'schemastery'] }, + // Mixes join(root, 'vendor', 'cordis') paths with real manifest names. + { file: 'packages/scaffold/helper/tests/documents.spec.ts', upstream: ['cordis'] }, + // `Symbol.for('schemastery')` and the `vendor:` metadata field are upstream identifiers. + { file: 'vendor/schemastery/src/index.ts', upstream: ['schemastery'] }, + // Asserts the vendored-manifest table, which gains an upstream-name column. + { file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream) }, + // `cordis` is also an agent-preset id — the directory name under + // apps/cli/config/agent-presets/ — so in these files the bare name is + // product data, not a package reference. Renaming it changed which preset + // the creator flow stages and which id the roster reports. + { file: 'packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/src/client/index.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/apply.spec.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/section.spec.tsx', upstream: ['cordis'] }, + { file: 'apps/cli/tests/web-agent-presets.e2e.ts', upstream: ['cordis'] }, + { file: 'apps/web/tests/agent-preset-authoring.e2e.ts', upstream: ['cordis'] }, + { file: 'packages/preset/agent-presets/tests/session.spec.ts', upstream: ['cordis'] }, + // The preset's own composition: its header comment and its system prompt name + // the preset a model mounts, so the scoped name would send the model after an + // id no roster reports. + { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', upstream: ['cordis'] }, + // GROUP_ORDER holds `packages/<group>/` directory names, not package names. + { file: 'scripts/gen-module-graph.ts', upstream: ['cordis'] }, + { file: 'scripts/gen-doc-graphs.ts', upstream: ['cordis'] }, +] + +/** A string that must appear exactly `count` times once the rescope has run. */ +interface PostCondition { + readonly file: string + readonly text: string + readonly count: number +} + +const POSTCONDITIONS: readonly PostCondition[] = [ + { file: 'vendor/cordis/package.json', text: '"name": "@deepseek-ai/cordis"', count: 1 }, + { file: 'vendor/hmr/package.json', text: '"name": "@deepseek-ai/cordis-plugin-hmr"', count: 1 }, + { file: 'scripts/cordis-walk.ts', text: '@deepseek-ai\\/cordis', count: 1 }, + { file: 'scripts/cordis-walk.ts', text: '!== \'@deepseek-ai/cordis\'', count: 1 }, + { file: 'scripts/gen-scoped-events.ts', text: '=== \'@deepseek-ai/cordis\'', count: 1 }, + { file: 'packages/typert/generator/src/analyzer.ts', text: '!== \'@deepseek-ai/cordis\'', count: 2 }, + { file: 'scripts/check-workspace-constraints.ts', text: '?.[\'@deepseek-ai/cordis\']', count: 2 }, + { file: 'packages/scaffold/helper/src/project/npm-dependency-policy.ts', text: '\'@deepseek-ai/cordis\': \'^4.0.0-rc.7\'', count: 1 }, + { file: 'packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts', text: '\'@deepseek-ai/cordis\': cordisSpec', count: 2 }, + { file: 'packages/boot/app-boot/tsdown.config.ts', text: '[\'@deepseek-ai/cordis-plugin-include\']', count: 1 }, + { file: 'tsconfig.base.json', text: '"@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"]', count: 1 }, + // One insertion, once: a duplicated log entry is what a non-idempotent apply produced. + { file: 'vendor/README.md', text: '15. **`@deepseek-ai` rescope**', count: 1 }, + { file: 'knip.json', text: '@cordisjs', count: 0 }, + { file: 'pnpm-workspace.yaml', text: 'cordis@4.0.0-rc.7', count: 0 }, + // The preset ids in this table are product data, not package names. + { file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', text: '[\'cordis\', \'presetCordisName\'', count: 1 }, + // The preset id the shipped composition documents to its own model. + { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'The `cordis` agent preset', count: 1 }, + { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'corrupting the `cordis` preset', count: 1 }, + // The vendor-directory paths in these fixtures must survive the rename. + { file: 'packages/scaffold/helper/tests/documents.spec.ts', text: 'join(root, \'vendor\', \'cordis\')', count: 2 }, + { file: 'packages/examples/acp-demo/tests/built-bin.e2e.ts', text: '\'cordis\', \'loader\', \'include\', \'timer\', \'hmr\', \'logger-console\',', count: 1 }, +] + +/** + * Every exact edit, in application order. Each `find` is written against the + * PRE-rename text because these run before the generic pass, so no `find` may + * quote a neighbouring line the generic pass would rewrite. + */ +const EXACT_EDITS: readonly ExactEdit[] = [ + { + id: 'cordis-walk-merge-head', + file: 'scripts/cordis-walk.ts', + find: 'const MERGE_HEAD = /declare module [\'"](?:cordis|\\.\\/context\\.ts)[\'"]/', + replace: 'const MERGE_HEAD = /declare module [\'"](?:@deepseek-ai\\/cordis|\\.\\/context\\.ts)[\'"]/', + expect: 1, + }, + { + id: 'constraints-manifest-lookup', + file: 'scripts/check-workspace-constraints.ts', + find: ` const peer = manifest.peerDependencies?.cordis + const dev = manifest.devDependencies?.cordis + + if (!peer) errors.push(\`\${label}: cordis must be a peerDependency\`) + if (!dev) errors.push(\`\${label}: cordis must also be a devDependency\`) + if (peer && dev && peer !== dev) { + errors.push(\`\${label}: cordis peer (\${peer}) and dev (\${dev}) ranges must match\`)`, + replace: ` const peer = manifest.peerDependencies?.['@deepseek-ai/cordis'] + const dev = manifest.devDependencies?.['@deepseek-ai/cordis'] + + if (!peer) errors.push(\`\${label}: @deepseek-ai/cordis must be a peerDependency\`) + if (!dev) errors.push(\`\${label}: @deepseek-ai/cordis must also be a devDependency\`) + if (peer && dev && peer !== dev) { + errors.push(\`\${label}: @deepseek-ai/cordis peer (\${peer}) and dev (\${dev}) ranges must match\`)`, + expect: 1, + }, + { + id: 'scaffold-dependency-policy', + file: 'packages/scaffold/helper/src/project/npm-dependency-policy.ts', + find: ' cordis: \'^4.0.0-rc.7\',', + replace: ' \'@deepseek-ai/cordis\': \'^4.0.0-rc.7\',', + expect: 1, + }, + { + id: 'scaffold-plugin-blueprint', + file: 'packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts', + find: ` cordis: cordisSpec, + }, + devDependencies: { + cordis: cordisSpec, + },`, + replace: ` '@deepseek-ai/cordis': cordisSpec, + }, + devDependencies: { + '@deepseek-ai/cordis': cordisSpec, + },`, + expect: 1, + }, + { + id: 'scaffold-link-workspace-lookup', + file: 'packages/scaffold/create-sdk/tests/link-workspace.e2e.ts', + find: 'manifest.dependencies.cordis', + replace: 'manifest.dependencies[\'@deepseek-ai/cordis\']', + expect: 1, + }, + { + id: 'documents-spec-manifest-name', + file: 'packages/scaffold/helper/tests/documents.spec.ts', + find: 'JSON.stringify({ name: \'cordis\' })', + replace: 'JSON.stringify({ name: \'@deepseek-ai/cordis\' })', + expect: 1, + }, + { + id: 'documents-spec-peer-key', + file: 'packages/scaffold/helper/tests/documents.spec.ts', + find: 'peerDependencies: { cordis: \'^4\' },', + replace: 'peerDependencies: { \'@deepseek-ai/cordis\': \'^4\' },', + expect: 1, + }, + { + id: 'documents-spec-closure-order', + file: 'packages/scaffold/helper/tests/documents.spec.ts', + find: ' \'@deepseek-ai/dsh-helper\', \'@deepseek-ai/dsh-scripts\', \'cordis\',', + replace: ' \'@deepseek-ai/cordis\', \'@deepseek-ai/dsh-helper\', \'@deepseek-ai/dsh-scripts\',', + expect: 1, + }, + { + id: 'documents-spec-lookups', + file: 'packages/scaffold/helper/tests/documents.spec.ts', + find: ` expect(manifest.npmDependency('cordis')?.spec).toMatch(/^link:/) + expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false') + expect(workspace.packageDirectory('cordis')).toBe(join(root, 'vendor', 'cordis')) + expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('cordis')`, + replace: ` expect(manifest.npmDependency('@deepseek-ai/cordis')?.spec).toMatch(/^link:/) + expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false') + expect(workspace.packageDirectory('@deepseek-ai/cordis')).toBe(join(root, 'vendor', 'cordis')) + expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('@deepseek-ai/cordis')`, + expect: 1, + }, + { + id: 'documents-spec-policy-lookup', + file: 'packages/scaffold/helper/tests/documents.spec.ts', + find: ' expect(resolveNpmDependency(\'cordis\', \'devDependencies\', \'0.0.1\')).toEqual({', + replace: ' expect(resolveNpmDependency(\'@deepseek-ai/cordis\', \'devDependencies\', \'0.0.1\')).toEqual({', + expect: 1, + }, + { + // The rescoped name is already covered by the `@deepseek-ai/.+` pattern beside it. + id: 'knip-logger-console', + file: 'knip.json', + find: ` "ignoreDependencies": [ + "@cordisjs/plugin-logger-console", + "@deepseek-ai/.+" + ] + }, + "packages/util/home": {`, + replace: ` "ignoreDependencies": [ + "@deepseek-ai/.+" + ] + }, + "packages/util/home": {`, + expect: 1, + }, + { + id: 'knip-bundle-base', + file: 'knip.json', + find: ` "packages/bundle/base": { + "ignoreDependencies": [ + "@deepseek-ai/.+", + "@cordisjs/.+" + ]`, + replace: ` "packages/bundle/base": { + "ignoreDependencies": [ + "@deepseek-ai/.+" + ]`, + expect: 1, + }, + { + // Rescoped packages are never fetched from a registry, so the exclusion is dead config. + id: 'pnpm-release-age', + file: 'pnpm-workspace.yaml', + find: `minimumReleaseAgeExclude: + # Cordis release candidates are source-vendored and pinned in vendor/README.md + # during the same-day sync that updates package manifests and the lockfile. + - '@cordisjs/plugin-loader@1.0.0-rc.5' + - cordis@4.0.0-rc.7 +`, + replace: 'minimumReleaseAgeExclude:\n', + expect: 1, + }, + { + id: 'publication-set-scope-assertion', + file: 'scripts/publish-npm-baseline.ts', + find: ' if (!isVendored && !name.startsWith(\'@deepseek-ai/\')) {', + replace: ` // Vendored packages are rescoped too (vendor/README.md), so publication + // never carries an upstream name that would squat it on the registry. + if (!name.startsWith('@deepseek-ai/')) {`, + expect: 1, + }, + { + id: 'vendor-readme-preamble', + file: 'vendor/README.md', + find: 'All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names.', + replace: 'All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`.', + expect: 1, + }, + { + id: 'vendor-readme-schemastery-note', + file: 'vendor/README.md', + find: 'whose lazy `require(\'cosmokit\')` can race', + replace: 'whose lazy `require(\'@deepseek-ai/cosmokit\')` can race', + expect: 1, + }, + { + id: 'vendor-readme-table-head', + file: 'vendor/README.md', + find: '| Directory | npm name | Version | Upstream repo | Commit |\n|---|---|---|---|---|', + replace: '| Directory | npm name | Upstream name | Version | Upstream repo | Commit |\n|---|---|---|---|---|---|', + expect: 1, + }, + { + id: 'vendor-readme-local-modification-log', + file: 'vendor/README.md', + find: '\n## Sync procedure', + replace: '15. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).\n\n## Sync procedure', + expect: 1, + }, + { + // A plain fence listing the bundle's mounted tree: a bare token, no quotes. + id: 'agent-spine-demo-mounted-tree', + file: 'packages/examples/agent-spine-demo/README.md', + find: '@cordisjs/plugin-timer timer service', + replace: '@deepseek-ai/cordis-plugin-timer timer service', + expect: 1, + }, + { + id: 'agent-spine-demo-mounted-tree-zh', + file: 'packages/examples/agent-spine-demo/README.zh.md', + find: '@cordisjs/plugin-timer timer service', + replace: '@deepseek-ai/cordis-plugin-timer timer service', + expect: 1, + }, + { + // The root contract claimed vendored packages keep their upstream names. + id: 'root-agents-vendored-name-contract', + file: 'AGENTS.md', + find: 'vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.', + replace: 'vendored packages are rescoped ([mapping](docs/rescope.md)) and `private: true`. `@deepseek-ai/cordis` is a peerDependency (+ dev) of every harness package.', + expect: 1, + }, + { + // The client purity gate reads `@deepseek-ai/` as "another plugin package". + // The rescope moves the vendored framework and its libraries into that + // namespace, where the gate would reject the library imports client + // bundles have always inlined, so it needs their names. + id: 'client-purity-vendored-libraries', + file: 'packages/client/tsdown.client.ts', + find: '/** Generated descriptor/codec contribution with no shared runtime identity. */', + replace: `/** + * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below + * would read them as plugin packages. They carry no cross-plugin runtime + * identity to share — the framework itself is a platform module (external), + * while these are ordinary libraries a browser bundle inlines. + */ +const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/ + +/** Generated descriptor/codec contribution with no shared runtime identity. */`, + expect: 1, + }, + { + id: 'client-purity-vendored-libraries-predicate', + file: 'packages/client/tsdown.client.ts', + find: ' if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point', + replace: ` if (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity + if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point`, + expect: 1, + }, + { + // The step-1 file tree told the reader to keep the upstream name, one + // paragraph above the invariant that says to rescope it. + id: 'vendoring-cookbook-tree-comment', + file: 'docs/cookbook/adding-a-vendored-package.md', + find: ' package.json # from upstream; set "private": true, keep name/exports/type', + replace: ' package.json # from upstream; set "private": true, rescope the name, keep exports/type', + expect: 1, + }, + { + id: 'vendoring-cookbook-tree-comment-zh', + file: 'docs/cookbook/adding-a-vendored-package.zh.md', + find: ' package.json # from upstream; set "private": true, keep name/exports/type', + replace: ' package.json # from upstream; set "private": true, rescope the name, keep exports/type', + expect: 1, + }, + { + // The checklist told the next vendoring to keep upstream's name. + id: 'vendoring-cookbook-name-invariant', + file: 'docs/cookbook/adding-a-vendored-package.md', + find: "keep upstream's `name`/`version`/`exports`/`type`", + replace: "rescope the `name` ([mapping](../rescope.md)) while keeping upstream's `version`/`exports`/`type`", + expect: 1, + }, + { + id: 'vendoring-cookbook-name-invariant-zh', + file: 'docs/cookbook/adding-a-vendored-package.zh.md', + find: '保留上游的 `name`/`version`/`exports`/`type`', + replace: '改写 `name` 的 scope([映射](../rescope.md)),保留上游的 `version`/`exports`/`type`', + expect: 1, + }, + { + // The real package references in files whose other `cordis` strings are preset ids. + id: 'agent-preset-spec-framework-import', + file: 'packages/client/ui-agent-preset/tests/apply.spec.ts', + find: "import { Context } from 'cordis'", + replace: "import { Context } from '@deepseek-ai/cordis'", + expect: 1, + }, + { + id: 'web-agent-presets-e2e-framework-import', + file: 'apps/cli/tests/web-agent-presets.e2e.ts', + find: "import { Context } from 'cordis'", + replace: "import { Context } from '@deepseek-ai/cordis'", + expect: 1, + }, + { + id: 'notices-vendored-row-type', + file: 'scripts/gen-third-party-notices.ts', + find: `export interface VendoredRow { + npmName: string + upstream: string +}`, + replace: `export interface VendoredRow { + npmName: string + /** The name this package carries upstream; MIT attribution names the fork's origin, not our scope. */ + upstreamName: string + upstream: string +}`, + expect: 1, + }, + { + id: 'notices-vendored-row-parse', + file: 'scripts/gen-third-party-notices.ts', + find: ` const match = /^\\| \\x60\\S+\\/\\x60 \\| \\x60([^\\x60]+)\\x60 \\| \\S+ \\| (https:\\/\\/\\S+?)(?: \\([^)]*\\))? \\| \\x60[0-9a-f]+\\x60 \\|$/.exec(line) + if (match === null) continue + const [, npmName, upstream] = match + if (npmName === undefined || upstream === undefined) continue + rows.push({ npmName, upstream })`, + replace: ` const match = new RegExp(String.raw\`^\\| \\x60\\S+\\/\\x60 \\| \\x60([^\\x60]+)\\x60 \\| \\x60([^\\x60]+)\\x60 \\| \\S+ \\| \` + + String.raw\`(https:\\/\\/\\S+?)(?: \\([^)]*\\))? \\| \\x60[0-9a-f]+\\x60 \\|$\`).exec(line) + if (match === null) continue + const [, npmName, upstreamName, upstream] = match + if (npmName === undefined || upstreamName === undefined || upstream === undefined) continue + rows.push({ npmName, upstreamName, upstream })`, + expect: 1, + }, + { + id: 'notices-vendored-section', + file: 'scripts/gen-third-party-notices.ts', + find: 'The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed', + replace: 'The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm, and republished under the \\`@deepseek-ai\\` scope. All are MIT-licensed', + expect: 1, + }, + { + id: 'notices-vendored-table', + file: 'scripts/gen-third-party-notices.ts', + find: `| Package | Upstream | License | +| --- | --- | --- | +\${vendored.map(row => \`| \\\`\${row.npmName}\\\` | [\${row.upstream.replace('https://', '')}](\${row.upstream}) | MIT |\`).join('\\n')}`, + replace: `| Package | Upstream name | Upstream | License | +| --- | --- | --- | --- | +\${vendored.map(row => \`| \\\`\${row.npmName}\\\` | \\\`\${row.upstreamName}\\\` | [\${row.upstream.replace('https://', '')}](\${row.upstream}) | MIT |\`).join('\\n')}`, + expect: 1, + }, + { + id: 'notices-spec-row-fixture', + file: 'scripts/gen-third-party-notices.spec.ts', + find: ' expect(rows).toContainEqual({ npmName: \'cordis\', upstream: \'https://github.com/cordiverse/cordis\' })', + replace: ` expect(rows).toContainEqual({ + npmName: '@deepseek-ai/cordis', + upstreamName: 'cordis', + upstream: 'https://github.com/cordiverse/cordis', + })`, + expect: 1, + }, + { + id: 'notices-spec-shape-fixture', + file: 'scripts/gen-third-party-notices.spec.ts', + find: 'parseVendoredRows(\'| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\\n\')', + replace: 'parseVendoredRows(\'| `cordis/` | `@deepseek-ai/cordis` | cordis | 4.0.0 | https://example.com | `abc123` |\\n\')', + expect: 1, + }, + { + // The framework peer is no longer a registry name, so the rehearsal must install this + // repository's vendored copies; cosmokit comes along as cordis's own dependency. + id: 'packed-install-vendored-peer', + file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts', + find: ` 'packages/support/invariants', +]`, + replace: ` 'packages/support/invariants', + // The framework and the vendored packages the closure declares outright: + // rescoped into @deepseek-ai, so the consumer installs this repository's + // copies. Schemastery is a hard dependency of three members above, not a + // peer, so npm resolves it while installing them. + 'vendor/cordis', + 'vendor/cosmokit', + 'vendor/schemastery', +]`, + expect: 1, + }, + { + id: 'packed-install-registry-spec', + file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts', + find: ` // Peer ranges resolve to the tarballs; Cordis is pinned to their peer range. Do not omit optional + // dependencies because the launcher selects its OS/CPU package through one. + writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' })) + const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.7'], {`, + replace: ` // Peer ranges resolve to the tarballs, the framework peer included. Do not omit optional + // dependencies because the launcher selects its OS/CPU package through one. + writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' })) + const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs], {`, + expect: 1, + }, + { + id: 'packed-install-module-doc', + file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts', + find: ` * Keyless publish-path rehearsal. It packs the provider, its workspace peers, and the current + * repository's Landlock entry/platform packages, then installs those exact tarballs in an external + * plain-Node consumer. The host launcher comes from the exact local tarballs, so no registry copy, + * tsx, path mapping, or workspace resolution can hide missing files, dependency errors, or lost + * executable modes.`, + replace: ` * Keyless publish-path rehearsal. It packs the provider, its workspace peers, the vendored framework + * peer, and the current repository's Landlock entry/platform packages, then installs those exact + * tarballs in an external plain-Node consumer. The host launcher comes from the exact local tarballs, + * so no registry copy, tsx, path mapping, or workspace resolution can hide missing files, dependency + * errors, or lost executable modes.`, + expect: 1, + }, + // The manifest table's name column plus the new upstream-name column, one edit per row. + ...RENAMES.map(rename => ({ + id: `vendor-readme-row-${rename.directory}`, + file: 'vendor/README.md', + find: `| \`${rename.directory}/\` | \`${rename.upstream}\` | `, + replace: `| \`${rename.directory}/\` | \`${rename.scoped}\` | \`${rename.upstream}\` | `, + expect: 1, + })), +] + +/** Files the rescope must never rewrite. */ +function excluded(file: string): boolean { + if (file === 'scripts/rescope-vendor.ts') return true // the mapping itself + if (file.startsWith('.agents/notes/')) return true // notes record what was true when written + // Recorded model payloads quote documentation verbatim, so they must mirror the + // sources on disk — including the notes this rescope leaves alone. + if (file.startsWith('scripts/snapshots/')) return true + // The mapping documents state both names on purpose. + if (file === 'docs/rescope.md' || file === 'docs/rescope.zh.md') return true + if (file.endsWith('.i18n.yaml')) return true // blob-hash records, re-recorded by the pairing gate + if (file === 'pnpm-lock.yaml') return true // regenerated by pnpm install + if (/^vendor\/[^/]+\/(README\.md|LICENSE)$/.test(file)) return true // upstream files kept verbatim + return !EXTENSIONS.some(extension => file.endsWith(extension)) +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** One name's rewrite, precompiled for both delimited forms. */ +interface Pattern { + readonly upstream: string + readonly from: string + readonly to: string + readonly token: RegExp + readonly yamlName: RegExp +} + +function patterns(reverse: boolean): Pattern[] { + return RENAMES + .map(rename => ({ + upstream: rename.upstream, + from: reverse ? rename.scoped : rename.upstream, + to: reverse ? rename.upstream : rename.scoped, + })) + .sort((left, right) => right.from.length - left.from.length) + .map(rename => ({ + ...rename, + token: new RegExp(`(['"\`])${escapeRegExp(rename.from)}((?:/[^'"\`\\s]*)?)\\1`, 'g'), + yamlName: new RegExp(`^(\\s*(?:-\\s*)?name:[ \\t]+)${escapeRegExp(rename.from)}([ \\t]*(?:#.*)?)$`, 'gm'), + })) +} + +function skipped(file: string, pattern: Pattern): boolean { + return GENERIC_SKIPS.some(skip => skip.file === file && skip.upstream.includes(pattern.upstream)) +} + +function rewriteLine(line: string, file: string, all: readonly Pattern[]): string { + let out = line + for (const pattern of all) { + if (skipped(file, pattern)) continue + out = out.replace(pattern.token, (_match, quote: string, subpath: string) => `${quote}${pattern.to}${subpath}${quote}`) + out = out.replace(pattern.yamlName, (_match, prefix: string, suffix: string) => `${prefix}${pattern.to}${suffix}`) + } + return out +} + +/** + * Rewrite a file's eligible lines. + * + * Markdown splits in two. Every fence is code a reader copies or a + * configuration they mount, so every fence follows the rename regardless of its + * info string. Prose follows it only under `docs/`, where a sentence quoting + * `` `cordis` `` teaches a name this repository no longer resolves; elsewhere + * prose is a record of what was true when it was written, and the same spelling + * can mean something else entirely — the Python SDK's `cordis` option, or the + * unvendored `@cordisjs/plugin-http`. + */ +function rewrite(text: string, file: string, all: readonly Pattern[]): { text: string; lines: number } { + const markdown = file.endsWith('.md') + const prose = markdown && file.startsWith('docs/') + let insideFence = false + let lines = 0 + const out = text.split('\n').map((line) => { + if (markdown) { + if (/^\s*```/.test(line)) { + insideFence = !insideFence + return line + } + if (!insideFence && !prose) return line + } + const next = rewriteLine(line, file, all) + if (next !== line) lines += 1 + return next + }) + return { text: out.join('\n'), lines } +} + +function classify(file: string): string { + if (/^vendor\/[^/]+\/package\.json$/.test(file)) return 'vendor manifest name' + if (file.endsWith('package.json')) return 'package.json dependencies' + if (/\.(ts|tsx|js|mjs|cjs|tpl)$/.test(file)) return 'code specifiers' + if (/\.(yml|yaml)$/.test(file)) return 'YAML plugin names' + if (file.endsWith('.json')) return 'JSON configuration' + return 'Markdown fences and docs prose' +} + +/** + * One exact edit's state in the text it targets. `pending` means the source + * form is present and the target form absent; `applied` means the reverse; + * anything else — a partial application, a moved site, or a DUPLICATED + * insertion — is `invalid`, so it fails the run instead of being applied again. + */ +export type ExactEditState = 'pending' | 'applied' | 'invalid' + +/** + * Classify one exact edit against its target text. + * + * An insertion keeps its anchor (`replace` contains `find`) and a deletion + * keeps its remainder (`find` contains `replace`), so neither can be judged by + * the source form alone: the surviving side counts the target form instead. + * @param text - the complete current text of the edited file. + * @param find - the source form, already oriented for the running direction. + * @param replace - the target form, already oriented for the running direction. + * @param expect - how many occurrences one complete application produces. + * @returns Whether the edit is pending, already applied, or invalid. + */ +export function exactEditState(text: string, find: string, replace: string, expect: number): ExactEditState { + const hits = text.split(find).length - 1 + const landed = text.split(replace).length - 1 + if (replace.includes(find)) { + if (landed === expect) return 'applied' + return landed === 0 && hits === expect ? 'pending' : 'invalid' + } + if (find.includes(replace)) { + if (hits === 0) return landed === expect ? 'applied' : 'invalid' + return hits === expect ? 'pending' : 'invalid' + } + if (hits === 0 && landed === expect) return 'applied' + return hits === expect && landed === 0 ? 'pending' : 'invalid' +} + +function main(): void { + const args = process.argv.slice(2) + const mode = args.includes('--apply') ? 'apply' : args.includes('--check') ? 'check' : 'dry' + const reverse = args.includes('--reverse') + const all = patterns(reverse) + const files = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' }) + .split('\0') + .filter(file => file !== '' && !excluded(file)) + + const counts = new Map<string, { files: number; lines: number }>() + const failures: string[] = [] + const outstanding: string[] = [] + + // Classify every exact edit before writing anything: a single invalid site + // means the mapping and the tree disagree, and a half-applied tree is worse + // than an untouched one. + const planned: { edit: ExactEdit; path: string; find: string; replace: string }[] = [] + for (const edit of EXACT_EDITS) { + const path = resolve(root, edit.file) + const before = readFileSync(path, 'utf8') + const find = reverse ? edit.replace : edit.find + const replace = reverse ? edit.find : edit.replace + const state = exactEditState(before, find, replace, edit.expect) + if (state === 'invalid') { + failures.push(`exact edit ${edit.id}: ${edit.file} is neither pending nor cleanly applied (duplicated, partial, or moved)`) + continue + } + if (mode === 'check') { + if (state !== 'applied') failures.push(`exact edit ${edit.id} did not land in ${edit.file}`) + continue + } + if (state === 'pending') planned.push({ edit, path, find, replace }) + } + if (failures.length > 0) { + for (const failure of failures) console.error(`rescope-vendor: ${failure}`) + console.error(`rescope-vendor: ${String(failures.length)} problem(s); nothing was written.`) + process.exitCode = 1 + return + } + if (mode === 'apply') { + // Re-read per edit: two edits can target one file, and a stale snapshot + // would let the second write discard the first. + for (const { path, find, replace } of planned) { + writeFileSync(path, readFileSync(path, 'utf8').split(find).join(replace)) + } + } + + for (const file of files) { + const path = resolve(root, file) + const before = readFileSync(path, 'utf8') + const { text: after, lines } = rewrite(before, file, all) + if (after === before) continue + outstanding.push(file) + const kind = classify(file) + const current = counts.get(kind) ?? { files: 0, lines: 0 } + counts.set(kind, { files: current.files + 1, lines: current.lines + lines }) + if (mode === 'apply') writeFileSync(path, after) + } + + console.log(`rescope-vendor: ${mode}${reverse ? ' --reverse' : ''} over ${String(files.length)} tracked files`) + for (const kind of [...counts.keys()].sort()) { + const { files: count, lines } = counts.get(kind) ?? { files: 0, lines: 0 } + console.log(` ${kind.padEnd(24)} ${String(count).padStart(4)} file(s), ${String(lines)} line(s)`) + } + + if (mode !== 'dry') { + for (const check of POSTCONDITIONS) { + if (reverse) break + const path = resolve(root, check.file) + const hits = existsSync(path) ? readFileSync(path, 'utf8').split(check.text).length - 1 : -1 + if (hits !== check.count) { + failures.push(`postcondition: ${check.file} has ${String(hits)} occurrence(s) of ${JSON.stringify(check.text)}, expected ${String(check.count)}`) + } + } + // The generic pass above already told us which files would still change, + // which in check mode is exactly the residue-and-idempotency signal. + if (mode === 'check') { + for (const file of outstanding) failures.push(`residue: ${file} still carries a pre-rescope name token`) + } + } + + if (failures.length > 0) { + for (const failure of failures) console.error(`rescope-vendor: ${failure}`) + console.error(`rescope-vendor: ${String(failures.length)} problem(s); the mapping or an upstream site moved.`) + process.exitCode = 1 + } else if (mode === 'check') { + console.log('rescope-vendor: post-state verified — no residue, every exact edit landed, idempotent.') + } else if (mode === 'apply') { + console.log('rescope-vendor: applied. Run `pnpm install`, `pnpm run gen-third-party-notices`, and re-record the touched bilingual pairs.') + } +} + +// Importing this module for its exported classifier must not run the codemod. +if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { + main() +} diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index b986b2e0f3..ad7b6842d9 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -553,6 +553,7 @@ function flagEnabled(envName: string): boolean { function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds } return [ + pnpmScript('rescope-vendor', 'rescope-vendor:check', { label: 'vendor rescope' }), pnpmScript('knip', 'knip'), pnpmScript('publint', 'publint', artifactOptions), pnpmScript('constraints', 'constraints'), From 78e9b8bec5df739acac847103637b4c389746ae6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:26:59 +0800 Subject: [PATCH 139/229] fix(scaffold): resolve the framework peer from this repository, not a registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two sites reached a registry for the vendored framework, which the rescope turns from a silent second copy into a hard failure. Live-link mode relinked only the root manifest, so a generated workspace member — `plugins/*/package.json` — resolved its own dependencies from the registry and installed upstream cordis beside this repository's vendored copy. `LinkWorkspace.relinkNestedManifest()` relinks every nested generated manifest; `peerDependencies` keeps its range because package managers reject a link spec there. The sandbox publish-path rehearsal installs this repository's vendored cordis and cosmokit tarballs instead of naming a registry version. --- .../src/package-managers/link-workspace.ts | 33 +++++++++++++++++++ .../src/project/project-edit-session.ts | 14 +++++++- .../scaffold/helper/tests/documents.spec.ts | 21 ++++++++++++ .../scaffold/helper/tests/project.spec.ts | 12 ++++++- 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/scaffold/helper/src/package-managers/link-workspace.ts b/packages/scaffold/helper/src/package-managers/link-workspace.ts index 1a6b518fa9..3fbc063451 100644 --- a/packages/scaffold/helper/src/package-managers/link-workspace.ts +++ b/packages/scaffold/helper/src/package-managers/link-workspace.ts @@ -134,4 +134,37 @@ export class LinkWorkspace { ? resolve(dirname(directory), directory.split(sep).at(-1) as string) : undefined } + + /** + * Rewrite one nested generated manifest's local dependencies to live-link specs. + * + * A generated workspace member resolves its own dependencies, so every local + * name it declares must point into this repository as well: none of them — + * the harness packages or the rescoped framework — exists on a public + * registry, so a semver spec there fails the install outright. + * `peerDependencies` keeps its range because a peer states what the consumer + * must supply, and package managers reject a link spec in that section. + * @param projectRoot - Absolute root of the generated project. + * @param manifestPath - The nested manifest's project-relative POSIX path. + * @param text - The nested manifest's complete current text. + * @param manager - Package manager whose link-spec form applies. + * @returns The manifest text with every resolved local dependency relinked. + */ + relinkNestedManifest(projectRoot: string, manifestPath: string, text: string, manager: PackageManager): string { + const manifest = JSON.parse(text) as Record<string, unknown> + const manifestDirectory = resolve(canonicalPath(projectRoot), dirname(manifestPath)) + let changed = false + for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) { + const dependencies = manifest[section] + if (typeof dependencies !== 'object' || dependencies === null) continue + for (const [name] of Object.entries(dependencies as Record<string, string>)) { + const pkg = this.packages.get(name) + if (!pkg) continue + const relativePath = posixPath(relative(manifestDirectory, realpathSync(pkg.directory))) + ;(dependencies as Record<string, string>)[name] = manager.linkSpec(relativePath) + changed = true + } + } + return changed ? `${JSON.stringify(manifest, null, 2)}\n` : text + } } diff --git a/packages/scaffold/helper/src/project/project-edit-session.ts b/packages/scaffold/helper/src/project/project-edit-session.ts index 4c3d39c07c..3d86050017 100644 --- a/packages/scaffold/helper/src/project/project-edit-session.ts +++ b/packages/scaffold/helper/src/project/project-edit-session.ts @@ -17,7 +17,7 @@ import type { ProjectResource } from '../features/resources.ts' import { CordisYamlFile, type CordisConfigEntry } from '../documents/cordis-yaml-file.ts' import { EnvFile } from '../documents/env-file.ts' import { PackageJsonFile, type PackageManifest } from '../documents/package-json-file.ts' -import { ProjectFile } from '../documents/project-file.ts' +import { ProjectFile, TextProjectFile } from '../documents/project-file.ts' import { TsConfigFile } from '../documents/tsconfig-file.ts' import { featureId, type FeatureId, type ResourceKey } from '../ids.ts' import { LinkWorkspace } from '../package-managers/link-workspace.ts' @@ -288,6 +288,18 @@ export class ProjectEditSession implements FeatureProjectView { this.profile.packageManager, [...this.documents.values()], ) + // Generated workspace members resolve their own dependencies, so the root + // manifest's links are not enough: relink every nested manifest as well. + for (const [path, document] of this.documents) { + if (path === 'package.json' || !path.endsWith('/package.json')) continue + const relinked = workspace.relinkNestedManifest( + this.source.root, + path, + document.serialize(), + this.profile.packageManager, + ) + this.documents.set(path, new TextProjectFile(path, relinked, document.originalText)) + } } this.validateFinalState() const changes = this.changes() diff --git a/packages/scaffold/helper/tests/documents.spec.ts b/packages/scaffold/helper/tests/documents.spec.ts index e3ffe18b77..317a9ed3ab 100644 --- a/packages/scaffold/helper/tests/documents.spec.ts +++ b/packages/scaffold/helper/tests/documents.spec.ts @@ -371,6 +371,27 @@ describe('package manager strategies', () => { expect(workspace.packageDirectory('cordis')).toBe(join(root, 'vendor', 'cordis')) expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('cordis') expect(workspace.packageDirectory('missing')).toBeUndefined() + // A generated workspace member resolves its own dependencies: every local name it + // declares relinks, while a peer keeps the range package managers require there. + const nested = workspace.relinkNestedManifest(join(root, 'consumer'), 'plugins/probe/package.json', `${JSON.stringify({ + name: 'probe', + dependencies: { '@deepseek-ai/dsh-helper': '^0.0.1', 'left-pad': '^1' }, + peerDependencies: { '@deepseek-ai/dsh-scripts': '^0.0.1' }, + devDependencies: { '@deepseek-ai/dsh-scripts': '^0.0.1' }, + }, null, 2)}\n`, new PnpmPackageManager('10.0.0')) + const nestedManifest = JSON.parse(nested) as { + dependencies: Record<string, string> + peerDependencies: Record<string, string> + devDependencies: Record<string, string> + } + expect(nestedManifest.dependencies['@deepseek-ai/dsh-helper']).toMatch(/^link:\.\.\/\.\.\//) + expect(nestedManifest.dependencies['left-pad']).toBe('^1') + expect(nestedManifest.devDependencies['@deepseek-ai/dsh-scripts']).toMatch(/^link:\.\.\/\.\.\//) + expect(nestedManifest.peerDependencies['@deepseek-ai/dsh-scripts']).toBe('^0.0.1') + // Nothing local to relink, and a non-object section, leave the text byte-identical. + const untouched = `${JSON.stringify({ name: 'probe', dependencies: { 'left-pad': '^1' }, devDependencies: null }, null, 2)}\n` + expect(workspace.relinkNestedManifest(join(root, 'consumer'), 'plugins/probe/package.json', untouched, new PnpmPackageManager('10.0.0'))) + .toBe(untouched) const yarnManifest = PackageJsonFile.create('{"name":"consumer"}') yarnManifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1') workspace.apply(join(root, 'consumer-yarn'), yarnManifest, new YarnPackageManager('4.0.0'), []) diff --git a/packages/scaffold/helper/tests/project.spec.ts b/packages/scaffold/helper/tests/project.spec.ts index 41acf530ba..01918d7570 100644 --- a/packages/scaffold/helper/tests/project.spec.ts +++ b/packages/scaffold/helper/tests/project.spec.ts @@ -698,14 +698,24 @@ describe('SdkProject and ProjectEditSession', () => { it('does not mistake a linked NPM dependency closure for an installed feature', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-link-closure-inspection-')) temporary.push(root) - const base = request([selection('hooks', ['claude'])]) + const base = request([selection('hooks', ['claude'])], [new LocalPluginBlueprint('probe', 'plugin')]) const creation: ProjectCreationRequest = { ...base, linkWorkspaceRoot: repoRoot } const project = SdkProject.create(root, creation) const registry = createBuiltinRegistry(project.profile) const edit = project.edit(registry) for (const item of creation.features) edit.installFeature(registry.get(item.id), item) + for (const blueprint of creation.localPlugins) edit.addPlugin(blueprint) const committed = (await edit.commit()).project expect(committed.packageManifest().dependencies?.['@deepseek-ai/dsh-subagent']).toMatch(/^file:/) + // A generated workspace member resolves its own dependencies, so its manifest links too. + const plugin = JSON.parse(await readFile(join(root, 'plugins/probe/package.json'), 'utf8')) as { + devDependencies?: Record<string, string> + peerDependencies?: Record<string, string> + } + // Asserted by shape, not by the framework's name: what matters is that the + // resolved section links into this repository while the peer keeps its range. + expect(Object.values(plugin.devDependencies ?? {}).every(spec => spec.startsWith('file:'))).toBe(true) + expect(Object.values(plugin.peerDependencies ?? {}).some(spec => spec.startsWith('^'))).toBe(true) expect(createBuiltinRegistry(committed.profile).get(featureId('subagent')).inspect(committed).state).toBe('absent') }) From ec601ca13dbc8a8a912d6e1b2f4a7550d2be6c9a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:04:06 +0800 Subject: [PATCH 140/229] build(vendor): rescope the vendored Cordis packages into @deepseek-ai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`, `verify-translation-pairing --write` for the touched bilingual pairs, `gen-doc-graphs`, and one typert snapshot whose ids embed character offsets. `pnpm run rescope-vendor --check` verifies the result. Renames nine vendored packages (cordis, cosmokit, schemastery and the six @cordisjs plugins) and every reference that resolves them: manifest names and dependency keys, module specifiers including declare-module merges, cordis.yml plugin names, tsconfig paths, every Markdown fence, and `docs/` prose. Directory names, upstream versions, and dependency ranges are unchanged, so vendor/README.md still reads as an upstream snapshot; its manifest table gains an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed at each fork's origin. The tutorial tier follows the rename end to end: its yaml fences named plugins the Loader can no longer resolve, its `ts ignore-check` fences disagreed with the compiled fences beside them, and its prose quoted both. The contracts that told readers to keep upstream names — the root convention and the vendoring cookbook's tree comment and manifest invariant — now say to rescope instead. Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle purity gate now names the vendored libraries a browser bundle inlines, and the files where a bare `cordis` is an agent-preset id keep that product data. --- AGENTS.md | 2 +- THIRD_PARTY_NOTICES.md | 24 +- apps/cli/composition.md | 8 +- apps/cli/package.json | 10 +- apps/cli/src/profile-boot.ts | 8 +- apps/cli/src/web.ts | 4 +- apps/cli/src/windows-shell.ts | 2 +- apps/cli/tests/args.spec.ts | 4 +- apps/cli/tests/fixtures/dsh-badge/snapshot.ts | 2 +- apps/cli/tests/fixtures/never-dispose.mjs | 2 +- apps/cli/tests/memory-mcp-configs.spec.ts | 4 +- apps/cli/tests/web-agent-presets.e2e.ts | 4 +- apps/web/package.json | 2 +- apps/web/tests/hmr-live.e2e.ts | 4 +- apps/web/tests/scaffold.ts | 10 +- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 4 +- docs/api-gateway.zh.md | 4 +- docs/cookbook/adding-a-package.i18n.yaml | 4 +- docs/cookbook/adding-a-package.md | 2 +- docs/cookbook/adding-a-package.zh.md | 2 +- docs/cookbook/adding-a-tool.i18n.yaml | 4 +- docs/cookbook/adding-a-tool.md | 2 +- docs/cookbook/adding-a-tool.zh.md | 2 +- .../adding-a-vendored-package.i18n.yaml | 4 +- docs/cookbook/adding-a-vendored-package.md | 4 +- docs/cookbook/adding-a-vendored-package.zh.md | 4 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 6 +- docs/cookbook/extension-cookbook.zh.md | 6 +- docs/cordis-primer.i18n.yaml | 4 +- docs/cordis-primer.md | 2 +- docs/cordis-primer.zh.md | 2 +- .../cordis-tutorial/01-first-plugin.i18n.yaml | 4 +- docs/cordis-tutorial/01-first-plugin.md | 4 +- docs/cordis-tutorial/01-first-plugin.zh.md | 4 +- .../02-lifecycle-and-effects.i18n.yaml | 4 +- .../02-lifecycle-and-effects.md | 2 +- .../02-lifecycle-and-effects.zh.md | 2 +- docs/cordis-tutorial/03-services.i18n.yaml | 4 +- docs/cordis-tutorial/03-services.md | 8 +- docs/cordis-tutorial/03-services.zh.md | 8 +- docs/cordis-tutorial/04-events.i18n.yaml | 4 +- docs/cordis-tutorial/04-events.md | 10 +- docs/cordis-tutorial/04-events.zh.md | 10 +- docs/cordis-tutorial/05-config.i18n.yaml | 4 +- docs/cordis-tutorial/05-config.md | 4 +- docs/cordis-tutorial/05-config.zh.md | 4 +- .../06-composition-and-hmr.i18n.yaml | 4 +- .../cordis-tutorial/06-composition-and-hmr.md | 16 +- .../06-composition-and-hmr.zh.md | 16 +- .../07-into-the-harness.i18n.yaml | 4 +- docs/cordis-tutorial/07-into-the-harness.md | 4 +- .../cordis-tutorial/07-into-the-harness.zh.md | 4 +- docs/cordis-tutorial/index.i18n.yaml | 4 +- docs/cordis-tutorial/index.md | 4 +- docs/cordis-tutorial/index.zh.md | 4 +- docs/user/develop/basic/config.i18n.yaml | 4 +- docs/user/develop/basic/config.md | 8 +- docs/user/develop/basic/config.zh.md | 8 +- docs/user/develop/basic/index.i18n.yaml | 4 +- docs/user/develop/basic/index.md | 12 +- docs/user/develop/basic/index.zh.md | 12 +- docs/user/develop/basic/tool.i18n.yaml | 4 +- docs/user/develop/basic/tool.md | 2 +- docs/user/develop/basic/tool.zh.md | 2 +- docs/user/develop/framework/events.i18n.yaml | 4 +- docs/user/develop/framework/events.md | 6 +- docs/user/develop/framework/events.zh.md | 6 +- docs/user/develop/framework/index.i18n.yaml | 4 +- docs/user/develop/framework/index.md | 4 +- docs/user/develop/framework/index.zh.md | 4 +- docs/user/develop/framework/service.i18n.yaml | 4 +- docs/user/develop/framework/service.md | 10 +- docs/user/develop/framework/service.zh.md | 10 +- docs/user/develop/practice/index.i18n.yaml | 4 +- docs/user/develop/practice/index.md | 8 +- docs/user/develop/practice/index.zh.md | 8 +- .../develop/practice/llm-adapter.i18n.yaml | 4 +- docs/user/develop/practice/llm-adapter.md | 4 +- docs/user/develop/practice/llm-adapter.zh.md | 4 +- .../acp-agent/advanced.cordis.snapshot.yml | 2 +- examples/acp-agent/advanced.cordis.yml | 2 +- .../acp-agent/both-mode.cordis.snapshot.yml | 2 +- examples/acp-agent/both-mode.cordis.yml | 2 +- .../child-question.cordis.snapshot.yml | 2 +- examples/acp-agent/child-question.cordis.yml | 2 +- ...mode-workspace-context.cordis.snapshot.yml | 2 +- .../code-mode-workspace-context.cordis.yml | 2 +- .../acp-agent/code-mode.cordis.snapshot.yml | 2 +- examples/acp-agent/code-mode.cordis.yml | 2 +- examples/acp-agent/cordis-tools.cordis.yml | 2 +- examples/acp-agent/cordis.snapshot.yml | 2 +- .../acp-agent/depth-two.cordis.snapshot.yml | 2 +- examples/acp-agent/depth-two.cordis.yml | 2 +- examples/acp-agent/fs.cordis.snapshot.yml | 2 +- examples/acp-agent/fs.cordis.yml | 2 +- .../partial-landlock.cordis.snapshot.yml | 2 +- .../acp-agent/partial-landlock.cordis.yml | 2 +- examples/acp-agent/pty.cordis.snapshot.yml | 2 +- examples/acp-agent/pty.cordis.yml | 2 +- examples/acp-agent/retry.cordis.snapshot.yml | 2 +- examples/acp-agent/retry.cordis.yml | 2 +- .../session-query.cordis.snapshot.yml | 2 +- examples/acp-agent/session-query.cordis.yml | 2 +- .../session-sandbox-root.cordis.snapshot.yml | 2 +- .../acp-agent/session-sandbox-root.cordis.yml | 2 +- .../session-title.cordis.snapshot.yml | 2 +- examples/acp-agent/session-title.cordis.yml | 2 +- ...ent-durability-failure.cordis.snapshot.yml | 2 +- .../subagent-durability-failure.cordis.yml | 2 +- .../tests/fixtures/child-question-tripwire.ts | 2 +- .../fixtures/subagent-durability-failure.ts | 2 +- .../fixtures/subagent-settlement-marker.ts | 2 +- .../subagent-acp/mock-delegating-llm.ts | 2 +- .../subagent/subagent-codex/fixture.ts | 2 +- .../fixtures/workspace-context-compaction.ts | 2 +- .../tests/fs-diff-bound.cordis.snapshot.yml | 2 +- .../acp-agent/tests/fs-diff-bound.cordis.yml | 2 +- .../acp-agent/tests/lsp.cordis.snapshot.yml | 2 +- examples/acp-agent/tests/lsp.cordis.yml | 2 +- examples/acp-agent/web.cordis.snapshot.yml | 2 +- examples/acp-agent/web.cordis.yml | 2 +- .../workspace-context.cordis.snapshot.yml | 2 +- .../acp-agent/workspace-context.cordis.yml | 2 +- .../advanced.cordis.snapshot.yml | 2 +- examples/headless-agent/advanced.cordis.yml | 2 +- .../compaction.cordis.snapshot.yml | 2 +- .../credentials.cordis.snapshot.yml | 2 +- examples/headless-agent/e2b.cordis.yml | 2 +- .../headless-agent/goal.cordis.snapshot.yml | 2 +- examples/headless-agent/goal.cordis.yml | 2 +- .../headless-agent/pty.cordis.snapshot.yml | 2 +- .../headless-agent/ralph.cordis.snapshot.yml | 2 +- .../headless-agent/retry.cordis.snapshot.yml | 2 +- .../headless-agent/tests/code-mode.e2e.ts | 2 +- .../headless-agent/tests/coding-task.e2e.ts | 2 +- .../headless-agent/tests/compaction.e2e.ts | 2 +- .../tests/fixtures/cli-mock-llm.ts | 2 +- .../tests/fixtures/cli.cordis.yml | 2 +- .../fixtures/deepseek-defaults.cordis.yml | 2 +- .../tests/fixtures/goal-domain/seed-goal.ts | 2 +- .../tests/fixtures/headless-driver.ts | 2 +- .../tests/fixtures/retry-snapshot-backend.mjs | 2 +- .../fixtures/semantic-checkpoint-agent.ts | 2 +- .../fixtures/subagent-diagnostic-agent.ts | 2 +- .../fixtures/subagent-inheritance-agent.ts | 2 +- .../tests/fixtures/telemetry-otel.cordis.yml | 2 +- .../tests/fixtures/telemetry-redact-rule.ts | 2 +- .../tests/fixtures/time-context-mock-llm.ts | 2 +- .../workspace-context-resume-agent.ts | 2 +- .../headless-agent/tests/full-loop.e2e.ts | 2 +- examples/headless-agent/tests/harness.ts | 2 +- examples/headless-agent/tests/resume.e2e.ts | 2 +- .../tests/semantic-checkpoint.snapshot.ts | 2 +- .../tests/subagent-diagnostic.snapshot.ts | 2 +- .../tests/subagent-inheritance.snapshot.ts | 2 +- .../headless-agent/tests/todo-write.e2e.ts | 2 +- .../workspace-context-resume.snapshot.ts | 2 +- examples/jsonrpc-agent/cordis.snapshot.yml | 2 +- .../persistent-tools.snapshot.cordis.yml | 2 +- .../subagent-dsh-sdk/child-mock-llm.ts | 2 +- .../subagent-dsh-sdk/mock-delegating-llm.ts | 2 +- examples/package.json | 8 +- knip.json | 4 +- packages/acp/acp/package.json | 6 +- packages/acp/acp/src/index.ts | 4 +- packages/acp/acp/src/invariant.ts | 2 +- packages/acp/acp/tests/harness.ts | 2 +- packages/api/gateway/package.json | 4 +- packages/api/gateway/src/client/index.ts | 6 +- packages/api/gateway/src/index.ts | 2 +- packages/api/gateway/src/invariant.ts | 2 +- packages/api/gateway/src/types.ts | 2 +- packages/api/gateway/tests/client.spec.ts | 2 +- packages/api/gateway/tests/gateway.spec.ts | 2 +- packages/api/remotes/package.json | 4 +- packages/api/remotes/src/agent-lookup.ts | 2 +- packages/api/remotes/src/client/index.ts | 4 +- packages/api/remotes/src/invariant.ts | 2 +- .../api/remotes/tests/agent-lookup.spec.ts | 2 +- packages/api/remotes/tests/built-lib.e2e.ts | 4 +- .../attachment/attachment-local/package.json | 6 +- .../attachment/attachment-local/src/index.ts | 4 +- .../attachment-local/src/invariant.ts | 2 +- .../attachment-local/tests/index.spec.ts | 2 +- packages/attachment/attachment/package.json | 4 +- packages/attachment/attachment/src/index.ts | 4 +- .../attachment/attachment/src/invariant.ts | 2 +- packages/bash/bash-env/README.i18n.yaml | 4 +- packages/bash/bash-env/README.md | 2 +- packages/bash/bash-env/README.zh.md | 2 +- packages/bash/bash-env/package.json | 6 +- packages/bash/bash-env/src/index.ts | 6 +- packages/bash/bash-env/src/invariant.ts | 2 +- packages/bash/bash-env/tests/bash-env.spec.ts | 2 +- packages/bash/bash-local/package.json | 6 +- packages/bash/bash-local/src/index.ts | 4 +- packages/bash/bash-local/src/invariant.ts | 2 +- .../bash/bash-local/tests/executor.spec.ts | 2 +- packages/bash/bash-sandbox/package.json | 4 +- packages/bash/bash-sandbox/src/index.ts | 2 +- packages/bash/bash-sandbox/src/invariant.ts | 2 +- packages/bash/bash-sandbox/tests/bwrap.e2e.ts | 2 +- .../bash/bash-sandbox/tests/landlock.e2e.ts | 2 +- .../tests/partial-landlock.spec.ts | 2 +- .../bash/bash-sandbox/tests/sandbox.spec.ts | 2 +- .../bash/bash-sandbox/tests/seatbelt.e2e.ts | 2 +- packages/bash/bash/package.json | 4 +- packages/bash/bash/src/index.ts | 4 +- packages/bash/bash/src/invariant.ts | 2 +- packages/bash/bash/tests/service.spec.ts | 2 +- packages/bash/pwsh-local/package.json | 6 +- packages/bash/pwsh-local/src/index.ts | 4 +- packages/bash/pwsh-local/src/invariant.ts | 2 +- .../bash/pwsh-local/tests/executor.spec.ts | 2 +- packages/bash/pwsh-sandbox/package.json | 4 +- packages/bash/pwsh-sandbox/src/index.ts | 2 +- packages/bash/pwsh-sandbox/src/invariant.ts | 2 +- packages/bash/pwsh-sandbox/tests/acl.e2e.ts | 2 +- .../bash/pwsh-sandbox/tests/sandbox.spec.ts | 2 +- packages/bash/tool-bash/package.json | 6 +- packages/bash/tool-bash/src/index.ts | 4 +- packages/bash/tool-bash/src/invariant.ts | 2 +- .../bash/tool-bash/tests/integration.spec.ts | 2 +- packages/bash/tool-bash/tests/tools.spec.ts | 2 +- packages/bash/tool-pwsh/package.json | 6 +- packages/bash/tool-pwsh/src/index.ts | 4 +- packages/bash/tool-pwsh/src/invariant.ts | 2 +- .../bash/tool-pwsh/tests/integration.spec.ts | 2 +- packages/bash/tool-pwsh/tests/tools.spec.ts | 2 +- packages/boot/app-boot/package.json | 24 +- packages/boot/app-boot/src/index.ts | 18 +- packages/boot/app-boot/src/invariant.ts | 2 +- packages/boot/app-boot/src/profile.ts | 4 +- packages/boot/app-boot/tests/app-boot.spec.ts | 2 +- .../boot/app-boot/tests/config-dump.spec.ts | 2 +- .../boot/app-boot/tests/config-reload.spec.ts | 6 +- .../boot/app-boot/tests/hmr-config.spec.ts | 8 +- .../boot/app-boot/tests/user-patches.spec.ts | 8 +- packages/boot/app-boot/tsdown.config.ts | 2 +- packages/bundle/base/cordis.patch.yml | 4 +- packages/bundle/base/package.json | 8 +- packages/bundle/base/src/invariant.ts | 2 +- packages/bundle/base/tests/base.spec.ts | 2 +- packages/bundle/headless/package.json | 8 +- packages/bundle/headless/src/index.ts | 8 +- packages/bundle/headless/src/invariant.ts | 2 +- .../bundle/headless/tests/headless.spec.ts | 2 +- packages/bundle/web-app/package.json | 6 +- packages/bundle/web-app/src/index.ts | 6 +- packages/bundle/web-app/src/invariant.ts | 2 +- packages/bundle/web-app/tests/web-app.spec.ts | 2 +- packages/client/connection/package.json | 6 +- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/src/index.ts | 4 +- packages/client/connection/src/invariant.ts | 2 +- packages/client/connection/src/rpc-host.ts | 4 +- .../connection/tests/client-apply.spec.ts | 2 +- .../client/connection/tests/node-half.spec.ts | 2 +- packages/client/hmr/package.json | 10 +- packages/client/hmr/src/client/index.ts | 4 +- packages/client/hmr/src/index.ts | 4 +- packages/client/hmr/src/invariant.ts | 2 +- packages/client/hmr/tests/node-half.spec.ts | 2 +- packages/client/locale/package.json | 6 +- packages/client/locale/src/client/index.ts | 4 +- packages/client/locale/src/index.ts | 2 +- packages/client/locale/src/invariant.ts | 2 +- packages/client/locale/src/locale-settings.ts | 2 +- packages/client/locale/tests/apply.spec.ts | 2 +- packages/client/locale/tests/host.spec.ts | 2 +- .../client/locale/tests/invariant.spec.ts | 2 +- packages/client/locale/tests/locale.spec.ts | 2 +- packages/client/modules/package.json | 6 +- packages/client/modules/src/client/index.ts | 2 +- .../client/modules/src/client/manifest.ts | 4 +- packages/client/modules/src/index.ts | 8 +- packages/client/modules/src/invariant.ts | 2 +- .../client/modules/tests/node-half.spec.ts | 2 +- packages/client/runtime/package.json | 6 +- .../client/runtime/src/client/agents/scope.ts | 4 +- .../runtime/src/client/contract/sessions.ts | 2 +- .../conversation/definition-registry.ts | 2 +- .../src/client/conversation/event-registry.ts | 2 +- .../src/client/conversation/view-registry.ts | 2 +- packages/client/runtime/src/client/index.ts | 4 +- .../src/client/session-history/service.ts | 2 +- .../runtime/src/client/sessions/service.ts | 2 +- .../runtime/src/client/sessions/session.ts | 2 +- .../runtime/src/client/settings-scope.ts | 2 +- packages/client/runtime/src/client/slots.ts | 4 +- .../runtime/src/client/workspaces/service.ts | 2 +- packages/client/runtime/src/invariant.ts | 2 +- .../client/runtime/tests/client-apply.spec.ts | 2 +- .../tests/conversation-registry.spec.ts | 2 +- .../client/runtime/tests/invariant.spec.ts | 2 +- packages/client/runtime/tests/scope.spec.ts | 4 +- .../runtime/tests/sessions-service.spec.ts | 2 +- .../runtime/tests/settings-scope.spec.ts | 4 +- .../runtime/tests/slots-service.spec.ts | 2 +- .../client/runtime/tests/wire-events.spec.ts | 2 +- .../runtime/tests/workspaces-service.spec.ts | 2 +- packages/client/schema-form/package.json | 6 +- packages/client/schema-form/src/invariant.ts | 2 +- packages/client/schema-form/src/model.ts | 2 +- .../schema-form/tests/invariant.spec.ts | 2 +- .../client/schema-form/tests/model.spec.ts | 2 +- packages/client/test-runtime/package.json | 4 +- packages/client/test-runtime/src/index.ts | 4 +- packages/client/test-runtime/src/invariant.ts | 2 +- packages/client/test-runtime/src/sessions.ts | 2 +- .../test-runtime/tests/invariant.spec.ts | 2 +- packages/client/tsdown.client.ts | 9 + packages/client/ui-agent-preset/package.json | 4 +- .../client/ui-agent-preset/src/invariant.ts | 2 +- .../ui-agent-preset/tests/apply.spec.ts | 2 +- .../ui-agent-preset/tests/invariant.spec.ts | 2 +- packages/client/ui-command/package.json | 4 +- .../client/ui-command/src/client/index.ts | 2 +- .../client/ui-command/src/client/service.ts | 4 +- packages/client/ui-command/src/invariant.ts | 2 +- .../ui-command/tests/browser-plugin.spec.ts | 2 +- .../client/ui-command/tests/service.spec.ts | 2 +- packages/client/ui-conversation/package.json | 6 +- .../ui-conversation/src/client/apply.ts | 2 +- .../client/chat/register-node-renderers.ts | 2 +- .../src/client/contract/slots.ts | 2 +- .../client/conversation-nodes/assistant.ts | 2 +- .../chat-snapshot-builder.ts | 2 +- .../src/client/conversation-nodes/command.ts | 2 +- .../client/conversation-nodes/compaction.ts | 2 +- .../src/client/conversation-nodes/fallback.ts | 2 +- .../src/client/conversation-nodes/inbox.ts | 2 +- .../src/client/conversation-nodes/message.ts | 2 +- .../src/client/conversation-nodes/register.ts | 2 +- .../src/client/conversation-nodes/retry.ts | 2 +- .../src/client/conversation-nodes/tool.ts | 2 +- .../client/conversation-nodes/turn-error.ts | 2 +- .../client/conversation-nodes/turn-tail.ts | 2 +- .../ui-conversation/src/client/index.ts | 2 +- .../src/client/queue/QueueDock.tsx | 2 +- .../ui-conversation/src/client/service.ts | 4 +- .../src/client/skeleton/TodoPanel.tsx | 2 +- packages/client/ui-conversation/src/index.ts | 2 +- .../client/ui-conversation/src/invariant.ts | 2 +- .../src/submission-settings.ts | 2 +- .../tests/coverage-tails.spec.tsx | 2 +- .../client/ui-conversation/tests/host.spec.ts | 2 +- .../tests/input-scenarios.spec.tsx | 2 +- .../tests/service-orchestration.spec.ts | 2 +- .../tests/views-type-chain.spec.tsx | 2 +- packages/client/ui-deliverables/package.json | 4 +- .../client/ui-deliverables/src/invariant.ts | 2 +- .../tests/produced-files.spec.tsx | 2 +- packages/client/ui-goal/package.json | 4 +- packages/client/ui-goal/src/invariant.ts | 2 +- .../ui-goal/tests/browser-plugin.spec.tsx | 2 +- packages/client/ui-layout/package.json | 4 +- packages/client/ui-layout/src/client/index.ts | 2 +- packages/client/ui-layout/src/invariant.ts | 2 +- packages/client/ui-layout/tests/apply.spec.ts | 2 +- packages/client/ui-model/package.json | 4 +- .../client/ui-model/src/client/service.ts | 6 +- packages/client/ui-model/src/invariant.ts | 2 +- .../ui-model/tests/browser-plugin.spec.ts | 2 +- packages/client/ui-models/package.json | 4 +- packages/client/ui-models/src/invariant.ts | 2 +- packages/client/ui-models/tests/apply.spec.ts | 2 +- .../ui-models/tests/components.spec.tsx | 2 +- .../client/ui-models/tests/invariant.spec.ts | 2 +- .../ui-models/tests/provider-form.spec.tsx | 2 +- packages/client/ui-permission/package.json | 4 +- .../client/ui-permission/src/invariant.ts | 2 +- .../tests/browser-plugin.spec.ts | 2 +- packages/client/ui-plan/package.json | 4 +- packages/client/ui-plan/src/invariant.ts | 2 +- .../ui-plan/tests/browser-plugin.spec.ts | 2 +- packages/client/ui-primitives/package.json | 4 +- .../client/ui-primitives/src/invariant.ts | 2 +- .../ui-primitives/tests/invariant.spec.ts | 2 +- packages/client/ui-question/package.json | 4 +- packages/client/ui-question/src/invariant.ts | 2 +- .../ui-question/tests/browser-plugin.spec.ts | 2 +- .../ui-question/tests/node-plugin.spec.ts | 2 +- .../client/ui-settings-general/package.json | 6 +- .../client/ui-settings-general/src/index.ts | 4 +- .../ui-settings-general/src/invariant.ts | 2 +- .../ui-settings-general/tests/apply.spec.ts | 2 +- .../ui-settings-general/tests/host.spec.ts | 2 +- .../tests/invariant.spec.ts | 2 +- packages/client/ui-settings/package.json | 4 +- packages/client/ui-settings/src/invariant.ts | 2 +- .../client/ui-settings/tests/apply.spec.ts | 2 +- .../ui-settings/tests/invariant.spec.ts | 2 +- packages/client/ui-sidebar/package.json | 4 +- packages/client/ui-sidebar/src/invariant.ts | 2 +- .../client/ui-sidebar/tests/apply.spec.tsx | 2 +- .../client/ui-sidebar/tests/invariant.spec.ts | 2 +- packages/client/ui-skill/package.json | 4 +- packages/client/ui-skill/src/invariant.ts | 2 +- .../ui-skill/tests/browser-plugin.spec.ts | 2 +- packages/client/ui-slash/package.json | 4 +- packages/client/ui-slash/src/client/index.ts | 2 +- .../client/ui-slash/src/client/service.ts | 4 +- packages/client/ui-slash/src/invariant.ts | 2 +- packages/client/ui-slash/src/types.ts | 2 +- packages/client/ui-slash/tests/apply.spec.ts | 2 +- .../client/ui-slash/tests/service.spec.ts | 2 +- packages/client/ui-slots/package.json | 4 +- packages/client/ui-slots/src/invariant.ts | 2 +- .../client/ui-slots/tests/invariant.spec.ts | 2 +- packages/client/ui-subagent/package.json | 4 +- packages/client/ui-subagent/src/invariant.ts | 2 +- .../ui-subagent/tests/browser-plugin.spec.ts | 2 +- packages/client/ui-theme/package.json | 6 +- packages/client/ui-theme/src/client/index.ts | 4 +- packages/client/ui-theme/src/index.ts | 2 +- packages/client/ui-theme/src/invariant.ts | 2 +- .../client/ui-theme/src/theme-settings.ts | 2 +- packages/client/ui-theme/tests/apply.spec.ts | 2 +- packages/client/ui-theme/tests/host.spec.ts | 2 +- .../client/ui-theme/tests/invariant.spec.ts | 2 +- packages/client/ui-theme/tests/theme.spec.ts | 2 +- packages/client/ui-tool/package.json | 4 +- .../tool/toolviews/ask-question-row.tsx | 2 +- .../src/client/tool/toolviews/bash-sample.tsx | 2 +- .../tool/toolviews/file-mutation-row.tsx | 2 +- .../src/client/tool/toolviews/read-row.tsx | 2 +- .../src/client/tool/toolviews/search-row.tsx | 2 +- .../src/client/tool/toolviews/todo-row.tsx | 2 +- .../src/client/tool/toolviews/web-row.tsx | 2 +- packages/client/ui-tool/src/invariant.ts | 2 +- .../ui-tool/tests/chat-code-subcalls.spec.tsx | 2 +- .../client/ui-tool/tests/read-card.spec.tsx | 2 +- .../client/ui-tool/tests/web-card.spec.tsx | 2 +- packages/client/ui-trajectory/package.json | 4 +- .../client/ui-trajectory/src/client/index.ts | 2 +- .../client/ui-trajectory/src/invariant.ts | 2 +- .../ui-trajectory/tests/client-bundle.spec.ts | 2 +- .../client/ui-trajectory/tests/views.spec.tsx | 2 +- packages/client/ui-workspace/package.json | 4 +- packages/client/ui-workspace/src/invariant.ts | 2 +- .../client/ui-workspace/tests/apply.spec.ts | 2 +- .../ui-workspace/tests/invariant.spec.ts | 2 +- packages/client/web-react/package.json | 4 +- packages/client/web-react/src/invariant.ts | 2 +- packages/client/web/package.json | 8 +- packages/client/web/src/app-shell.ts | 4 +- packages/client/web/src/app.tsx | 2 +- packages/client/web/src/boot.tsx | 4 +- packages/client/web/src/invariant.ts | 2 +- packages/client/web/src/loader-status.ts | 2 +- packages/client/web/src/platform.ts | 2 +- packages/client/web/src/seed.ts | 4 +- packages/client/web/tests/app-shell.spec.tsx | 2 +- packages/client/web/tests/app.spec.tsx | 2 +- .../code-runtime-worker/package.json | 6 +- .../code-runtime-worker/src/index.ts | 4 +- .../code-runtime-worker/src/invariant.ts | 2 +- .../tests/built-lib.e2e.ts | 2 +- .../code-runtime-worker/tests/runtime.spec.ts | 2 +- .../code-runtime/code-runtime/package.json | 4 +- .../code-runtime/code-runtime/src/index.ts | 4 +- .../code-runtime/src/invariant.ts | 2 +- .../code-runtime/tests/service.spec.ts | 2 +- packages/compact/command-compact/package.json | 8 +- packages/compact/command-compact/src/index.ts | 2 +- .../compact/command-compact/src/invariant.ts | 2 +- .../tests/command-compact.spec.ts | 4 +- .../tests/loader-composition.spec.ts | 6 +- .../compact/compact-basic/README.i18n.yaml | 4 +- packages/compact/compact-basic/README.md | 2 +- packages/compact/compact-basic/README.zh.md | 2 +- packages/compact/compact-basic/package.json | 10 +- packages/compact/compact-basic/src/index.ts | 4 +- .../compact/compact-basic/src/invariant.ts | 2 +- .../compact/compact-basic/src/summarizer.ts | 2 +- .../compact-basic/tests/compact-basic.spec.ts | 2 +- .../tests/compact-loop-repro.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 6 +- .../tests/manual-compact.spec.ts | 2 +- .../README.i18n.yaml | 4 +- .../compact-tool-result-prune/README.md | 2 +- .../compact-tool-result-prune/README.zh.md | 2 +- .../compact-tool-result-prune/package.json | 10 +- .../compact-tool-result-prune/src/index.ts | 6 +- .../src/invariant.ts | 2 +- .../tests/loader-composition.spec.ts | 6 +- .../tests/tool-result-prune.spec.ts | 2 +- packages/compact/compact/package.json | 4 +- packages/compact/compact/src/index.ts | 4 +- packages/compact/compact/src/invariant.ts | 2 +- .../compact/compact/tests/compact.spec.ts | 2 +- .../compact/compact/tests/invariant.spec.ts | 2 +- .../context/session-reference/package.json | 6 +- .../context/session-reference/src/index.ts | 6 +- .../session-reference/src/invariant.ts | 2 +- .../tests/session-reference.spec.ts | 2 +- packages/context/time-context/package.json | 6 +- packages/context/time-context/src/index.ts | 4 +- .../context/time-context/src/invariant.ts | 2 +- .../time-context/tests/invariant.spec.ts | 2 +- .../time-context/tests/time-context.spec.ts | 4 +- packages/context/tmux-context/package.json | 6 +- packages/context/tmux-context/src/index.ts | 4 +- .../context/tmux-context/src/invariant.ts | 2 +- .../tmux-context/tests/tmux-context.spec.ts | 2 +- .../context/workspace-context/package.json | 8 +- .../context/workspace-context/src/config.ts | 2 +- .../context/workspace-context/src/index.ts | 2 +- .../workspace-context/src/invariant.ts | 2 +- .../tests/workspace-context.e2e.ts | 2 +- .../tests/workspace-context.spec.ts | 4 +- .../core/agent-default-model/package.json | 6 +- .../core/agent-default-model/src/index.ts | 6 +- .../core/agent-default-model/src/invariant.ts | 2 +- .../tests/agent-default-model.spec.ts | 2 +- packages/core/agent-loop/package.json | 6 +- packages/core/agent-loop/src/agent.ts | 2 +- packages/core/agent-loop/src/index.ts | 6 +- packages/core/agent-loop/src/invariant.ts | 2 +- .../core/agent-loop/src/runtime-context.ts | 2 +- packages/core/agent-loop/src/tool-calls.ts | 2 +- .../agent-loop/tests/agent-initiator.spec.ts | 2 +- packages/core/agent-loop/tests/agent.spec.ts | 2 +- packages/core/agent-loop/tests/cancel.spec.ts | 2 +- .../tests/config-session-id.spec.ts | 2 +- .../tests/contract-regressions.spec.ts | 2 +- .../agent-loop/tests/coverage-edges.spec.ts | 2 +- .../agent-loop/tests/interception.spec.ts | 2 +- .../core/agent-loop/tests/invariant.spec.ts | 2 +- packages/core/agent-loop/tests/loop.spec.ts | 2 +- .../core/agent-loop/tests/properties.spec.ts | 2 +- .../agent-loop/tests/request-cache.e2e.ts | 2 +- .../agent-loop/tests/request-error.spec.ts | 2 +- .../tests/request-reconstruction.spec.ts | 2 +- packages/core/agent-loop/tests/resume.spec.ts | 2 +- .../agent-loop/tests/runtime-context.spec.ts | 2 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 2 +- .../core/agent-loop/tests/tool-calls.spec.ts | 2 +- .../core/agent-loop/tests/tool-order.spec.ts | 2 +- packages/core/agent-tool-mode/package.json | 6 +- packages/core/agent-tool-mode/src/index.ts | 4 +- .../core/agent-tool-mode/src/invariant.ts | 2 +- .../tests/agent-tool-mode.spec.ts | 2 +- packages/core/agent/package.json | 4 +- packages/core/agent/src/dispatch.ts | 2 +- packages/core/agent/src/index.ts | 6 +- packages/core/agent/src/invariant.ts | 2 +- packages/core/agent/src/model-selection.ts | 2 +- packages/core/agent/src/runtime-types.ts | 4 +- .../core/agent/tests/agent-initiator.spec.ts | 2 +- packages/core/agent/tests/agent.spec.ts | 2 +- packages/core/agent/tests/invariant.spec.ts | 2 +- .../core/agent/tests/model-selection.spec.ts | 2 +- .../agent/tests/verify-export-jsdoc.spec.ts | 2 +- packages/core/scope/package.json | 4 +- packages/core/scope/src/index.ts | 4 +- packages/core/scope/src/invariant.ts | 2 +- packages/core/scope/src/store.ts | 2 +- packages/core/scope/tests/invariant.spec.ts | 4 +- packages/core/scope/tests/scope.spec.ts | 4 +- packages/core/scope/tests/store.spec.ts | 2 +- packages/core/session/package.json | 4 +- packages/core/session/src/index.ts | 4 +- packages/core/session/src/invariant.ts | 2 +- packages/core/session/tests/fork.spec.ts | 2 +- packages/core/session/tests/invariant.spec.ts | 2 +- packages/core/session/tests/scoped.spec.ts | 2 +- packages/core/session/tests/session.spec.ts | 2 +- packages/core/session/tests/typert.spec.ts | 2 +- packages/core/system-prompt/package.json | 6 +- packages/core/system-prompt/src/index.ts | 6 +- packages/core/system-prompt/src/invariant.ts | 2 +- .../system-prompt/tests/invariant.spec.ts | 2 +- .../core/system-prompt/tests/scoped.spec.ts | 2 +- .../system-prompt/tests/system-prompt.spec.ts | 2 +- .../system-prompt/tests/tool-order.spec.ts | 2 +- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/package.json | 6 +- packages/core/tools/src/index.ts | 6 +- packages/core/tools/src/invariant.ts | 2 +- packages/core/tools/tests/code-mode.spec.ts | 2 +- .../core/tools/tests/execution-mode.spec.ts | 2 +- .../tests/execution-signal-types.spec.ts | 2 +- packages/core/tools/tests/invariant.spec.ts | 2 +- packages/core/tools/tests/scoped.spec.ts | 4 +- packages/core/tools/tests/tools.spec.ts | 2 +- .../credentials-local/package.json | 6 +- .../credentials-local/src/index.ts | 4 +- .../credentials-local/src/invariant.ts | 2 +- .../credentials-local/tests/drain.spec.ts | 2 +- .../credentials-local/tests/local.spec.ts | 2 +- .../tests/review-fixes.spec.ts | 2 +- .../credentials-local/tests/watcher.spec.ts | 2 +- .../credentials/credentials/README.i18n.yaml | 4 +- packages/credentials/credentials/README.md | 2 +- packages/credentials/credentials/README.zh.md | 2 +- packages/credentials/credentials/package.json | 4 +- packages/credentials/credentials/src/index.ts | 4 +- .../credentials/credentials/src/invariant.ts | 2 +- .../credentials/tests/credentials.spec.ts | 2 +- .../credentials/tests/invariant.spec.ts | 2 +- .../credentials/credentials/tests/memory.ts | 2 +- packages/e2b/e2b/package.json | 6 +- packages/e2b/e2b/src/index.ts | 6 +- packages/e2b/e2b/src/invariant.ts | 2 +- packages/e2b/e2b/tests/composition.e2e.ts | 2 +- packages/e2b/e2b/tests/e2b.spec.ts | 2 +- packages/e2b/fs-e2b/package.json | 4 +- packages/e2b/fs-e2b/src/invariant.ts | 2 +- packages/e2b/fs-e2b/tests/filesystem.spec.ts | 2 +- packages/e2b/subprocess-e2b/package.json | 6 +- packages/e2b/subprocess-e2b/src/index.ts | 4 +- packages/e2b/subprocess-e2b/src/invariant.ts | 2 +- .../subprocess-e2b/tests/subprocess.spec.ts | 2 +- .../e2b/subprocess-e2b/tests/terminal.spec.ts | 2 +- packages/examples/acp-demo/package.json | 16 +- packages/examples/acp-demo/src/index.ts | 4 +- packages/examples/acp-demo/src/invariant.ts | 2 +- .../examples/acp-demo/tests/acp-agent.spec.ts | 4 +- .../agent-spine-demo/README.i18n.yaml | 4 +- packages/examples/agent-spine-demo/README.md | 2 +- .../examples/agent-spine-demo/README.zh.md | 2 +- .../examples/agent-spine-demo/package.json | 10 +- .../examples/agent-spine-demo/src/index.ts | 6 +- .../agent-spine-demo/src/invariant.ts | 2 +- .../agent-spine-demo/tests/agent-core.spec.ts | 4 +- .../tests/gen-config-catalog.spec.ts | 70 +- .../tests/multi-project-sandbox.e2e.ts | 2 +- packages/examples/jsonrpc-demo/package.json | 4 +- .../examples/jsonrpc-demo/src/invariant.ts | 2 +- .../feedback/command-feedback/package.json | 8 +- .../feedback/command-feedback/src/index.ts | 2 +- .../command-feedback/src/invariant.ts | 2 +- .../tests/command-feedback.spec.ts | 4 +- .../tests/loader-composition.spec.ts | 6 +- packages/fs/fs-local/package.json | 6 +- packages/fs/fs-local/src/index.ts | 4 +- packages/fs/fs-local/src/invariant.ts | 2 +- packages/fs/fs-local/tests/filesystem.spec.ts | 2 +- packages/fs/fs-policy/README.i18n.yaml | 4 +- packages/fs/fs-policy/README.md | 2 +- packages/fs/fs-policy/README.zh.md | 2 +- packages/fs/fs-policy/package.json | 4 +- packages/fs/fs-policy/src/index.ts | 2 +- packages/fs/fs-policy/src/invariant.ts | 2 +- packages/fs/fs-policy/tests/policy.spec.ts | 2 +- packages/fs/fs-sandbox/package.json | 4 +- packages/fs/fs-sandbox/src/index.ts | 2 +- packages/fs/fs-sandbox/src/invariant.ts | 2 +- .../fs/fs-sandbox/tests/fs-sandbox.spec.ts | 2 +- packages/fs/fs/package.json | 4 +- packages/fs/fs/src/index.ts | 4 +- packages/fs/fs/src/invariant.ts | 2 +- packages/fs/fs/tests/invariant.spec.ts | 2 +- packages/fs/fs/tests/service.spec.ts | 2 +- packages/fs/tool-fs-search/package.json | 6 +- packages/fs/tool-fs-search/src/glob.ts | 2 +- packages/fs/tool-fs-search/src/grep.ts | 2 +- packages/fs/tool-fs-search/src/index.ts | 4 +- packages/fs/tool-fs-search/src/invariant.ts | 2 +- packages/fs/tool-fs-search/src/search-core.ts | 2 +- packages/fs/tool-fs-search/src/surface.ts | 2 +- .../tool-fs-search/tests/integration.spec.ts | 2 +- .../fs/tool-fs-search/tests/load-path.spec.ts | 4 +- .../fs/tool-fs-search/tests/rg-path.spec.ts | 2 +- .../fs/tool-fs-search/tests/tools.spec.ts | 2 +- packages/fs/tool-fs/package.json | 6 +- packages/fs/tool-fs/src/edit.ts | 2 +- packages/fs/tool-fs/src/index.ts | 4 +- packages/fs/tool-fs/src/invariant.ts | 2 +- packages/fs/tool-fs/src/read.ts | 2 +- packages/fs/tool-fs/src/sandbox.ts | 2 +- packages/fs/tool-fs/src/write.ts | 2 +- packages/fs/tool-fs/tests/fs-tools.e2e.ts | 2 +- packages/fs/tool-fs/tests/harness.ts | 2 +- packages/fs/tool-fs/tests/integration.spec.ts | 2 +- packages/fs/tool-fs/tests/tools.spec.ts | 2 +- .../fs/tool-str-replace-editor/package.json | 6 +- .../fs/tool-str-replace-editor/src/index.ts | 4 +- .../tool-str-replace-editor/src/invariant.ts | 2 +- .../tests/tools.spec.ts | 2 +- packages/goal/command-goal/package.json | 6 +- packages/goal/command-goal/src/index.ts | 2 +- packages/goal/command-goal/src/invariant.ts | 2 +- .../command-goal/tests/command-goal.spec.ts | 4 +- packages/goal/goal-session/package.json | 4 +- packages/goal/goal-session/src/index.ts | 4 +- packages/goal/goal-session/src/invariant.ts | 2 +- .../goal-session/tests/goal-session.spec.ts | 2 +- .../goal/goal-session/tests/invariant.spec.ts | 2 +- packages/goal/goal/package.json | 6 +- packages/goal/goal/src/domain.ts | 2 +- packages/goal/goal/src/index.ts | 6 +- packages/goal/goal/src/invariant.ts | 2 +- packages/goal/goal/tests/goal.spec.ts | 2 +- packages/goal/goal/tests/invariant.spec.ts | 2 +- packages/goal/goal/tests/projection.spec.ts | 2 +- packages/goal/tool-goal/package.json | 8 +- packages/goal/tool-goal/src/authority.ts | 2 +- packages/goal/tool-goal/src/index.ts | 4 +- packages/goal/tool-goal/src/invariant.ts | 2 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 4 +- packages/guard/repeat-tool-guard/package.json | 6 +- packages/guard/repeat-tool-guard/src/index.ts | 4 +- .../guard/repeat-tool-guard/src/invariant.ts | 2 +- .../tests/repeat-tool-guard.spec.ts | 2 +- packages/guard/timeout-policy/package.json | 4 +- packages/guard/timeout-policy/src/index.ts | 2 +- .../guard/timeout-policy/src/invariant.ts | 2 +- .../tests/timeout-policy.spec.ts | 4 +- packages/hooks/hook-protocol/package.json | 4 +- packages/hooks/hook-protocol/src/invariant.ts | 2 +- .../hook-protocol/tests/invariant.spec.ts | 2 +- packages/hooks/hooks-claude/package.json | 6 +- packages/hooks/hooks-claude/src/index.ts | 4 +- packages/hooks/hooks-claude/src/invariant.ts | 2 +- .../hooks/hooks-claude/tests/bridge.spec.ts | 4 +- .../hooks-claude/tests/coverage-cases.ts | 2 +- packages/hooks/hooks-codex/package.json | 6 +- packages/hooks/hooks-codex/src/index.ts | 4 +- packages/hooks/hooks-codex/src/invariant.ts | 2 +- .../hooks/hooks-codex/tests/bridge.spec.ts | 4 +- .../hooks/hooks-codex/tests/coverage-cases.ts | 2 +- packages/host/apiproxy/package.json | 6 +- packages/host/apiproxy/src/api-proxy.ts | 2 +- packages/host/apiproxy/src/index.ts | 6 +- packages/host/apiproxy/src/invariant.ts | 2 +- .../tests/api-proxy-agent-preset.spec.ts | 2 +- .../apiproxy/tests/api-proxy-approval.spec.ts | 2 +- .../apiproxy/tests/api-proxy-blank.spec.ts | 2 +- .../apiproxy/tests/api-proxy-cold.spec.ts | 2 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 2 +- .../apiproxy/tests/api-proxy-config.spec.ts | 4 +- .../apiproxy/tests/api-proxy-fork.spec.ts | 2 +- .../apiproxy/tests/api-proxy-models.spec.ts | 2 +- .../tests/api-proxy-projections.spec.ts | 2 +- .../apiproxy/tests/api-proxy-question.spec.ts | 2 +- .../apiproxy/tests/api-proxy-rename.spec.ts | 2 +- .../apiproxy/tests/api-proxy-search.spec.ts | 2 +- .../tests/api-proxy-subagents.spec.ts | 2 +- .../apiproxy/tests/api-proxy-view.spec.ts | 2 +- .../tests/api-proxy-workspace.spec.ts | 2 +- .../host/directory-picker-auto/package.json | 10 +- .../host/directory-picker-auto/src/index.ts | 4 +- .../directory-picker-auto/src/invariant.ts | 2 +- .../tests/loader-composition.spec.ts | 6 +- .../host/directory-picker-browse/package.json | 6 +- .../host/directory-picker-browse/src/index.ts | 4 +- .../directory-picker-browse/src/invariant.ts | 2 +- .../tests/client-flow.spec.tsx | 2 +- .../tests/service.spec.ts | 2 +- .../host/directory-picker-native/package.json | 4 +- .../directory-picker-native/src/invariant.ts | 2 +- .../tests/client-flow.spec.tsx | 2 +- .../tests/service.spec.ts | 2 +- packages/host/directory-picker/package.json | 4 +- packages/host/directory-picker/src/index.ts | 4 +- .../host/directory-picker/src/invariant.ts | 2 +- .../host/directory-picker/tests/seam.spec.ts | 2 +- packages/host/frontend-static/package.json | 8 +- packages/host/frontend-static/src/index.ts | 4 +- .../host/frontend-static/src/invariant.ts | 2 +- .../tests/frontend-static.spec.ts | 6 +- packages/host/webserver/package.json | 6 +- packages/host/webserver/src/index.ts | 6 +- packages/host/webserver/src/invariant.ts | 2 +- .../host/webserver/tests/webserver.spec.ts | 6 +- packages/interaction/commands/package.json | 4 +- packages/interaction/commands/src/index.ts | 4 +- .../interaction/commands/src/invariant.ts | 2 +- .../commands/tests/commands.spec.ts | 2 +- .../commands/tests/invariant.spec.ts | 2 +- packages/interaction/permission/package.json | 6 +- packages/interaction/permission/src/index.ts | 6 +- .../interaction/permission/src/invariant.ts | 2 +- .../permission/tests/invariant.spec.ts | 2 +- .../permission/tests/permission.spec.ts | 2 +- .../permission/tests/projection.spec.ts | 2 +- .../interaction/tool-ask-user/package.json | 4 +- .../interaction/tool-ask-user/src/index.ts | 2 +- .../tool-ask-user/src/invariant.ts | 2 +- .../tool-ask-user/tests/tool-ask-user.spec.ts | 2 +- .../interaction/user-approval/package.json | 6 +- .../interaction/user-approval/src/index.ts | 6 +- .../user-approval/src/invariant.ts | 2 +- .../user-approval/tests/approval.spec.ts | 2 +- .../user-approval/tests/invariant.spec.ts | 2 +- .../interaction/user-interaction/package.json | 4 +- .../interaction/user-interaction/src/index.ts | 4 +- .../user-interaction/src/invariant.ts | 2 +- .../tests/user-interaction.spec.ts | 2 +- packages/llm/llm-deepseek/package.json | 6 +- packages/llm/llm-deepseek/src/index.ts | 4 +- packages/llm/llm-deepseek/src/invariant.ts | 2 +- .../llm/llm-deepseek/tests/adapter.e2e.ts | 2 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 2 +- packages/llm/llm-deepseek/tests/assemble.ts | 2 +- .../llm-deepseek/tests/dynamic-config.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 6 +- packages/llm/llm-pi-ai/package.json | 6 +- packages/llm/llm-pi-ai/src/config.ts | 2 +- packages/llm/llm-pi-ai/src/index.ts | 2 +- packages/llm/llm-pi-ai/src/invariant.ts | 2 +- packages/llm/llm-pi-ai/tests/adapter.e2e.ts | 2 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 2 +- packages/llm/llm-pi-ai/tests/assemble.ts | 2 +- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 2 +- .../llm/llm-pi-ai/tests/discovery.spec.ts | 2 +- .../llm-pi-ai/tests/dynamic-config.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 6 +- .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 2 +- packages/llm/llm-retry/package.json | 10 +- packages/llm/llm-retry/src/index.ts | 4 +- packages/llm/llm-retry/src/invariant.ts | 2 +- .../llm/llm-retry/tests/invariant.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 6 +- .../llm/llm-retry/tests/persistence.spec.ts | 2 +- packages/llm/llm-retry/tests/retry.spec.ts | 4 +- .../tests/transport-recovery.spec.ts | 2 +- packages/llm/llm/package.json | 6 +- packages/llm/llm/src/index.ts | 4 +- packages/llm/llm/src/invariant.ts | 2 +- packages/llm/llm/src/retry-policy.ts | 2 +- packages/llm/llm/tests/invariant.spec.ts | 2 +- packages/llm/llm/tests/service.spec.ts | 2 +- packages/llm/llm/tests/topology.spec.ts | 2 +- packages/llm/token-meter/package.json | 6 +- packages/llm/token-meter/src/index.ts | 6 +- packages/llm/token-meter/src/invariant.ts | 2 +- .../context-breakdown-projection.spec.ts | 2 +- .../llm/token-meter/tests/token-meter.spec.ts | 2 +- .../tests/token-usage-projection.spec.ts | 2 +- packages/lsp/lsp-local/package.json | 6 +- packages/lsp/lsp-local/src/index.ts | 4 +- packages/lsp/lsp-local/src/invariant.ts | 2 +- packages/lsp/lsp-local/tests/built-lib.e2e.ts | 2 +- packages/lsp/lsp-local/tests/host.spec.ts | 2 +- packages/lsp/lsp-local/tests/instance.spec.ts | 2 +- .../lsp/lsp-local/tests/lifecycle.spec.ts | 2 +- packages/lsp/lsp-local/tests/provider.spec.ts | 2 +- .../lsp-local/tests/typescript-server.e2e.ts | 2 +- packages/lsp/lsp/package.json | 4 +- packages/lsp/lsp/src/index.ts | 4 +- packages/lsp/lsp/src/invariant.ts | 2 +- packages/lsp/lsp/tests/lsp.spec.ts | 2 +- packages/lsp/tool-lsp/package.json | 6 +- packages/lsp/tool-lsp/src/index.ts | 4 +- packages/lsp/tool-lsp/src/invariant.ts | 2 +- .../lsp/tool-lsp/tests/integration.spec.ts | 2 +- packages/lsp/tool-lsp/tests/load-path.spec.ts | 2 +- packages/lsp/tool-lsp/tests/tool-lsp.spec.ts | 2 +- packages/mcp/mcp-client/package.json | 6 +- packages/mcp/mcp-client/src/index.ts | 4 +- packages/mcp/mcp-client/src/invariant.ts | 2 +- packages/mcp/mcp-client/src/tools.ts | 2 +- packages/mcp/mcp-client/tests/apply.spec.ts | 2 +- .../mcp/mcp-client/tests/load-path.spec.ts | 2 +- .../mcp/mcp-client/tests/mcp-client.e2e.ts | 2 +- .../mcp/mcp-client/tests/mcp-client.spec.ts | 2 +- packages/plan/plan-mode/package.json | 4 +- packages/plan/plan-mode/src/index.ts | 4 +- packages/plan/plan-mode/src/invariant.ts | 2 +- .../plan/plan-mode/tests/integration.spec.ts | 2 +- .../plan/plan-mode/tests/invariant.spec.ts | 2 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 2 +- .../plan/plan-mode/tests/projection.spec.ts | 2 +- packages/preset/agent-presets/package.json | 14 +- .../preset/agent-presets/src/discovery.ts | 2 +- packages/preset/agent-presets/src/index.ts | 6 +- .../preset/agent-presets/src/invariant.ts | 2 +- packages/preset/agent-presets/src/mount.ts | 6 +- .../agent-presets/tests/authoring.spec.ts | 6 +- .../agent-presets/tests/invariant.spec.ts | 6 +- .../preset/agent-presets/tests/mount.spec.ts | 8 +- .../agent-presets/tests/settings.spec.ts | 6 +- packages/preset/persona/package.json | 6 +- packages/preset/persona/src/index.ts | 4 +- packages/preset/persona/src/invariant.ts | 2 +- packages/preset/persona/tests/persona.spec.ts | 2 +- packages/pty/pty-local/package.json | 6 +- packages/pty/pty-local/src/config.ts | 2 +- packages/pty/pty-local/src/index.ts | 2 +- packages/pty/pty-local/src/invariant.ts | 2 +- packages/pty/pty-local/tests/index.spec.ts | 4 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/pty/pty/package.json | 4 +- packages/pty/pty/src/index.ts | 4 +- packages/pty/pty/src/invariant.ts | 2 +- packages/pty/pty/tests/service.spec.ts | 2 +- .../pty/tool-bash-persistent/package.json | 10 +- .../pty/tool-bash-persistent/src/index.ts | 4 +- .../pty/tool-bash-persistent/src/invariant.ts | 2 +- .../tests/loader-composition.spec.ts | 6 +- .../tool-bash-persistent/tests/tools.spec.ts | 2 +- packages/pty/tool-pty/package.json | 10 +- packages/pty/tool-pty/src/index.ts | 4 +- packages/pty/tool-pty/src/invariant.ts | 2 +- .../tool-pty/tests/loader-composition.spec.ts | 6 +- packages/pty/tool-pty/tests/tools.spec.ts | 2 +- packages/sandbox/sandbox-local/package.json | 6 +- packages/sandbox/sandbox-local/src/index.ts | 4 +- .../sandbox/sandbox-local/src/invariant.ts | 2 +- .../sandbox-local/tests/acl-grants.spec.ts | 2 +- .../sandbox/sandbox-local/tests/bwrap.e2e.ts | 2 +- .../sandbox-local/tests/landlock.e2e.ts | 2 +- .../sandbox/sandbox-local/tests/local.spec.ts | 2 +- .../sandbox-local/tests/packed-install.e2e.ts | 23 +- .../sandbox-local/tests/seatbelt.e2e.ts | 2 +- packages/sandbox/sandbox-policy/package.json | 6 +- packages/sandbox/sandbox-policy/src/index.ts | 6 +- .../sandbox/sandbox-policy/src/invariant.ts | 2 +- .../sandbox-policy/tests/invariant.spec.ts | 2 +- .../sandbox-policy/tests/policy.spec.ts | 2 +- .../sandbox/sandbox-windows-acl/package.json | 4 +- .../sandbox-windows-acl/src/invariant.ts | 2 +- .../tests/provider-chain.spec.ts | 2 +- packages/sandbox/sandbox/package.json | 4 +- packages/sandbox/sandbox/src/index.ts | 4 +- packages/sandbox/sandbox/src/invariant.ts | 2 +- packages/scaffold/client/package.json | 4 +- packages/scaffold/client/src/invariant.ts | 2 +- packages/scaffold/create-sdk/package.json | 4 +- packages/scaffold/create-sdk/src/invariant.ts | 2 +- .../create-sdk/tests/link-workspace.e2e.ts | 4 +- packages/scaffold/helper/package.json | 4 +- .../helper/src/features/builtin/index.ts | 2 +- .../helper/src/features/builtin/spine.ts | 2 +- packages/scaffold/helper/src/invariant.ts | 2 +- .../src/package-managers/link-workspace.ts | 2 +- .../src/plugins/local-plugin-blueprint.ts | 6 +- .../src/project/npm-dependency-policy.ts | 8 +- .../src/templates/assets/local-plugin.ts.tpl | 2 +- .../src/templates/assets/local-tool.ts.tpl | 2 +- .../scaffold/helper/tests/documents.spec.ts | 16 +- .../scaffold/helper/tests/project.spec.ts | 8 +- packages/scaffold/protocol/package.json | 4 +- packages/scaffold/protocol/src/invariant.ts | 2 +- packages/scaffold/scripts/package.json | 4 +- packages/scaffold/scripts/src/invariant.ts | 2 +- packages/scaffold/scripts/src/runtime.ts | 2 +- .../scaffold/scripts/tests/scripts.spec.ts | 2 +- packages/scaffold/server/package.json | 8 +- packages/scaffold/server/src/index.ts | 4 +- packages/scaffold/server/src/invariant.ts | 2 +- packages/scaffold/server/src/server.ts | 2 +- .../server/tests/plugin-apply.spec.ts | 2 +- .../server/tests/plugin-shape.spec.ts | 2 +- packages/scaffold/server/tests/server.spec.ts | 2 +- packages/scaffold/telemetry/package.json | 4 +- packages/scaffold/telemetry/src/invariant.ts | 2 +- .../tool-cordis/package.json | 10 +- .../tool-cordis/src/fiber-state.ts | 2 +- .../tool-cordis/src/guard.ts | 4 +- .../tool-cordis/src/index.ts | 4 +- .../tool-cordis/src/inspect.ts | 2 +- .../tool-cordis/src/invariant.ts | 2 +- .../tool-cordis/src/mount.ts | 2 +- .../tests/cordis-lifecycle.spec.ts | 2 +- .../tool-cordis/tests/helpers.ts | 4 +- .../tool-cordis/tests/inspect.spec.ts | 2 +- .../tool-cordis/tests/integration.spec.ts | 2 +- .../tool-cordis/tests/tool-cordis.spec.ts | 2 +- .../tool-cordis/tests/unmount-hmr.spec.ts | 2 +- .../session-query-sqlite/package.json | 8 +- .../session-query-sqlite/src/index.ts | 6 +- .../session-query-sqlite/src/invariant.ts | 2 +- .../tests/load-path.e2e.ts | 4 +- .../session-query-sqlite/tests/sqlite.spec.ts | 2 +- .../session-query/session-query/package.json | 4 +- .../session-query/session-query/src/corpus.ts | 2 +- .../session-query/session-query/src/index.ts | 4 +- .../session-query/src/invariant.ts | 2 +- .../tests/search-helpers.spec.ts | 2 +- .../session-query/tests/session-query.spec.ts | 2 +- .../session-query/tests/tracing.spec.ts | 2 +- .../tool-session-query/package.json | 6 +- .../tool-session-query/src/index.ts | 4 +- .../tool-session-query/src/invariant.ts | 2 +- .../tool-session-query/src/operations.ts | 2 +- .../src/service-boundary.ts | 2 +- .../src/workspace-access.ts | 2 +- .../tests/sqlite-integration.spec.ts | 2 +- .../tests/tool-session-query.spec.ts | 2 +- .../session-checkpoint-policy/package.json | 6 +- .../session-checkpoint-policy/src/index.ts | 2 +- .../src/invariant.ts | 2 +- .../tests/crash-recovery.e2e.ts | 2 +- .../tests/fixtures/crash-child.ts | 2 +- .../tests/session-checkpoint-policy.spec.ts | 4 +- .../session-persistence-jsonl/package.json | 6 +- .../session-persistence-jsonl/src/index.ts | 4 +- .../src/invariant.ts | 2 +- .../tests/jsonl.spec.ts | 2 +- .../tests/zstd.spec.ts | 2 +- .../session-persistence-sqlite/package.json | 6 +- .../session-persistence-sqlite/src/index.ts | 4 +- .../src/invariant.ts | 2 +- .../tests/sqlite.spec.ts | 2 +- .../session/session-persistence/package.json | 4 +- .../session-persistence/src/coordinator.ts | 2 +- .../session/session-persistence/src/index.ts | 4 +- .../session-persistence/src/invariant.ts | 2 +- .../tests/coordinator-contract.ts | 2 +- .../tests/persistence.spec.ts | 2 +- .../session-projection-cache/package.json | 6 +- .../session-projection-cache/src/index.ts | 6 +- .../session-projection-cache/src/invariant.ts | 2 +- .../tests/cache.spec.ts | 2 +- .../session/session-projection/package.json | 4 +- .../session/session-projection/src/index.ts | 4 +- .../session-projection/src/invariant.ts | 2 +- .../session-projection/tests/registry.spec.ts | 2 +- .../session-telemetry-otel/package.json | 8 +- .../session-telemetry-otel/src/index.ts | 4 +- .../session-telemetry-otel/src/invariant.ts | 2 +- .../session-telemetry-otel/tests/otel.spec.ts | 4 +- .../session/session-telemetry/package.json | 4 +- .../session-telemetry/src/coordinator.ts | 2 +- .../session/session-telemetry/src/index.ts | 4 +- .../session-telemetry/src/invariant.ts | 2 +- .../session-telemetry/tests/redact.spec.ts | 2 +- .../session-telemetry/tests/telemetry.spec.ts | 2 +- .../package.json | 6 +- .../src/index.ts | 4 +- .../src/invariant.ts | 2 +- .../tests/provider.spec.ts | 2 +- .../package.json | 10 +- .../src/index.ts | 4 +- .../src/invariant.ts | 2 +- .../tests/loader-composition.spec.ts | 6 +- .../tests/provider.e2e.ts | 2 +- .../tests/provider.spec.ts | 2 +- .../session/session-title-llm/package.json | 6 +- .../session/session-title-llm/src/index.ts | 4 +- .../session-title-llm/src/invariant.ts | 2 +- .../session-title-llm/tests/llm.spec.ts | 2 +- packages/session/session-title/package.json | 6 +- packages/session/session-title/src/index.ts | 6 +- .../session/session-title/src/invariant.ts | 2 +- .../session-title/tests/invariant.spec.ts | 2 +- .../session-title/tests/persistence.spec.ts | 2 +- .../session-title/tests/projection.spec.ts | 2 +- .../session-title/tests/provider.spec.ts | 2 +- .../session-title/tests/rename.spec.ts | 2 +- .../tests/service-contracts.spec.ts | 2 +- .../session-title/tests/session-title.spec.ts | 2 +- packages/session/user-id/package.json | 4 +- packages/session/user-id/src/invariant.ts | 2 +- .../session/user-id/tests/invariant.spec.ts | 2 +- packages/settings/settings-local/package.json | 6 +- packages/settings/settings-local/src/index.ts | 4 +- .../settings/settings-local/src/invariant.ts | 2 +- .../settings-local/tests/concurrency.spec.ts | 4 +- .../tests/loader-composition.spec.ts | 8 +- .../settings-local/tests/local.spec.ts | 4 +- .../settings-local/tests/lock-race.spec.ts | 4 +- .../settings-local/tests/watcher.spec.ts | 4 +- packages/settings/settings/package.json | 8 +- packages/settings/settings/src/index.ts | 6 +- packages/settings/settings/src/invariant.ts | 2 +- packages/settings/settings/src/redact.ts | 2 +- .../settings/settings/tests/invariant.spec.ts | 4 +- .../settings/settings/tests/redact.spec.ts | 4 +- .../settings/settings/tests/settings.spec.ts | 4 +- packages/skill/skill-badge/package.json | 4 +- packages/skill/skill-badge/src/index.ts | 2 +- packages/skill/skill-badge/src/invariant.ts | 2 +- .../skill-badge/tests/skill-badge.spec.ts | 2 +- packages/skill/skill-local/package.json | 6 +- packages/skill/skill-local/src/index.ts | 6 +- packages/skill/skill-local/src/invariant.ts | 2 +- .../tests/skill-local-watcher.spec.ts | 2 +- .../skill-local/tests/skill-local.spec.ts | 2 +- packages/skill/skill/package.json | 6 +- packages/skill/skill/src/index.ts | 8 +- packages/skill/skill/src/invariant.ts | 2 +- packages/skill/skill/tests/skill.spec.ts | 2 +- packages/skill/tool-skill/package.json | 6 +- packages/skill/tool-skill/src/index.ts | 4 +- packages/skill/tool-skill/src/invariant.ts | 2 +- .../skill/tool-skill/tests/tool-skill.spec.ts | 2 +- packages/spill/spill-local/package.json | 6 +- packages/spill/spill-local/src/index.ts | 4 +- packages/spill/spill-local/src/invariant.ts | 2 +- .../spill-local/tests/spill-local.spec.ts | 2 +- packages/spill/spill-policy/package.json | 6 +- packages/spill/spill-policy/src/index.ts | 4 +- packages/spill/spill-policy/src/invariant.ts | 2 +- .../spill-policy/tests/spill-policy.spec.ts | 4 +- packages/spill/spill/package.json | 4 +- packages/spill/spill/src/index.ts | 4 +- packages/spill/spill/src/invariant.ts | 2 +- packages/spill/spill/tests/service.spec.ts | 2 +- packages/storage/storage-domain/package.json | 6 +- packages/storage/storage-domain/src/domain.ts | 2 +- packages/storage/storage-domain/src/events.ts | 2 +- packages/storage/storage-domain/src/index.ts | 6 +- .../storage/storage-domain/src/invariant.ts | 2 +- .../storage-domain/tests/domain.spec.ts | 2 +- .../storage-domain/tests/invariant.spec.ts | 2 +- packages/storage/storage-json/package.json | 6 +- packages/storage/storage-json/src/index.ts | 4 +- .../storage/storage-json/src/invariant.ts | 2 +- .../storage-json/tests/json-backend.spec.ts | 2 +- packages/storage/storage-sqlite/package.json | 6 +- packages/storage/storage-sqlite/src/index.ts | 4 +- .../storage/storage-sqlite/src/invariant.ts | 2 +- .../storage-sqlite/tests/invariant.spec.ts | 2 +- .../tests/sqlite-backend.spec.ts | 2 +- packages/storage/storage/package.json | 4 +- packages/storage/storage/src/index.ts | 4 +- packages/storage/storage/src/invariant.ts | 2 +- .../storage/storage/tests/registry.spec.ts | 2 +- packages/subagent/subagent-acp/package.json | 8 +- packages/subagent/subagent-acp/src/index.ts | 4 +- .../subagent/subagent-acp/src/invariant.ts | 2 +- .../subagent-acp/tests/subagent-acp.e2e.ts | 2 +- .../subagent-acp/tests/subagent-acp.spec.ts | 4 +- .../subagent-claude-code/package.json | 6 +- .../subagent-claude-code/src/index.ts | 4 +- .../subagent-claude-code/src/invariant.ts | 2 +- .../tests/real-deepseek.e2e.ts | 2 +- .../tests/real-product.spec.ts | 2 +- .../tests/subagent-claude-code.spec.ts | 4 +- packages/subagent/subagent-codex/package.json | 8 +- packages/subagent/subagent-codex/src/index.ts | 4 +- .../subagent/subagent-codex/src/invariant.ts | 2 +- .../subagent-codex/tests/real-deepseek.e2e.ts | 2 +- .../subagent-codex/tests/real-product.spec.ts | 2 +- .../tests/subagent-codex.spec.ts | 4 +- .../subagent/subagent-dsh-sdk/package.json | 8 +- .../subagent/subagent-dsh-sdk/src/index.ts | 4 +- .../subagent-dsh-sdk/src/invariant.ts | 2 +- .../tests/subagent-dsh-sdk.spec.ts | 2 +- packages/subagent/subagent-fork/package.json | 8 +- packages/subagent/subagent-fork/src/index.ts | 4 +- .../subagent/subagent-fork/src/invariant.ts | 2 +- .../tests/multi-subagent.spec.ts | 2 +- .../subagent-fork/tests/subagent-fork.spec.ts | 4 +- .../subagent/subagent-inprocess/package.json | 8 +- .../subagent/subagent-inprocess/src/index.ts | 2 +- .../subagent-inprocess/src/invariant.ts | 2 +- .../subagent-inprocess/src/structured.ts | 2 +- .../tests/inheritance.spec.ts | 2 +- .../tests/preset-inheritance.spec.ts | 6 +- .../tests/structured.spec.ts | 2 +- .../tests/subagent-inprocess.spec.ts | 2 +- packages/subagent/subagent-spawn/package.json | 8 +- packages/subagent/subagent-spawn/src/index.ts | 4 +- .../subagent/subagent-spawn/src/invariant.ts | 2 +- .../subagent/subagent-spawn/tests/harness.ts | 2 +- .../subagent-spawn/tests/spawn.e2e.ts | 2 +- .../tests/subagent-spawn.spec.ts | 4 +- packages/subagent/subagent/package.json | 4 +- .../subagent/src/activation-setup-registry.ts | 2 +- packages/subagent/subagent/src/child-agent.ts | 2 +- .../subagent/subagent/src/continuation.ts | 2 +- packages/subagent/subagent/src/index.ts | 4 +- packages/subagent/subagent/src/invariant.ts | 2 +- packages/subagent/subagent/src/lifecycle.ts | 2 +- .../subagent/subagent/src/list-children.ts | 2 +- .../tests/activation-setup-registry.spec.ts | 2 +- .../subagent/tests/continuation.spec.ts | 2 +- .../subagent/subagent/tests/invariant.spec.ts | 2 +- .../subagent/tests/list-children.spec.ts | 2 +- .../subagent/subagent/tests/service.spec.ts | 2 +- .../subagent/tests/timing-projection.spec.ts | 2 +- .../tool-subagent-control/package.json | 4 +- .../tool-subagent-control/src/index.ts | 2 +- .../tool-subagent-control/src/invariant.ts | 2 +- .../tool-subagent-control/src/list-agents.ts | 2 +- .../tests/list-agents.spec.ts | 2 +- .../tests/tool-subagent-control.spec.ts | 2 +- .../tool-subagent-report/package.json | 6 +- .../tool-subagent-report/src/index.ts | 4 +- .../tool-subagent-report/src/invariant.ts | 2 +- .../tests/tool-subagent-report.spec.ts | 2 +- packages/subagent/tool-subagent/package.json | 8 +- packages/subagent/tool-subagent/src/index.ts | 4 +- .../subagent/tool-subagent/src/invariant.ts | 2 +- .../tests/scripted-provider.spec.ts | 2 +- .../tool-subagent/tests/scripted-provider.ts | 2 +- .../tool-subagent/tests/tool-subagent.spec.ts | 4 +- .../subprocess/subprocess-local/package.json | 4 +- .../subprocess/subprocess-local/src/index.ts | 2 +- .../subprocess-local/src/invariant.ts | 2 +- .../subprocess-local/tests/local.spec.ts | 2 +- .../subprocess-local/tests/spawn.spec.ts | 2 +- packages/subprocess/subprocess/package.json | 4 +- packages/subprocess/subprocess/src/index.ts | 4 +- .../subprocess/subprocess/src/invariant.ts | 2 +- .../subprocess/tests/service.spec.ts | 2 +- packages/support/acp-snapshot/package.json | 4 +- .../support/acp-snapshot/src/invariant.ts | 2 +- .../agent-loop-testkit/README.i18n.yaml | 4 +- packages/support/agent-loop-testkit/README.md | 2 +- .../support/agent-loop-testkit/README.zh.md | 2 +- .../support/agent-loop-testkit/package.json | 4 +- .../support/agent-loop-testkit/src/index.ts | 2 +- .../agent-loop-testkit/src/invariant.ts | 2 +- .../tests/agent-loop-testkit.spec.ts | 2 +- packages/support/invariants/README.i18n.yaml | 4 +- packages/support/invariants/README.md | 2 +- packages/support/invariants/README.zh.md | 2 +- packages/support/invariants/package.json | 6 +- packages/support/invariants/src/index.ts | 10 +- packages/support/invariants/src/invariant.ts | 2 +- .../support/invariants/tests/service.spec.ts | 4 +- packages/support/llm-mock-server/package.json | 4 +- .../support/llm-mock-server/src/invariant.ts | 2 +- .../llm-mock-server/tests/invariant.spec.ts | 2 +- packages/support/llm-replay/package.json | 4 +- packages/support/llm-replay/src/index.ts | 2 +- packages/support/llm-replay/src/invariant.ts | 2 +- .../llm-replay/tests/llm-replay.spec.ts | 2 +- packages/support/loader-smoke/package.json | 4 +- .../support/loader-smoke/src/agent-turn.ts | 2 +- .../support/loader-smoke/src/invariant.ts | 2 +- .../loader-smoke/tests/agent-turn.spec.ts | 2 +- packages/tasks/tasks-local/package.json | 4 +- packages/tasks/tasks-local/src/index.ts | 2 +- packages/tasks/tasks-local/src/invariant.ts | 2 +- .../tasks/tasks-local/tests/tasks.spec.ts | 2 +- packages/tasks/tasks/package.json | 4 +- packages/tasks/tasks/src/index.ts | 4 +- packages/tasks/tasks/src/invariant.ts | 2 +- packages/tasks/tasks/tests/invariant.spec.ts | 2 +- packages/tasks/tasks/tests/service.spec.ts | 2 +- packages/tasks/tool-tasks/package.json | 6 +- packages/tasks/tool-tasks/src/index.ts | 4 +- packages/tasks/tool-tasks/src/invariant.ts | 2 +- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 2 +- packages/todo/tool-todo/package.json | 10 +- packages/todo/tool-todo/src/index.ts | 4 +- packages/todo/tool-todo/src/invariant.ts | 2 +- .../todo/tool-todo/tests/integration.spec.ts | 2 +- .../todo/tool-todo/tests/invariant.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 6 +- .../todo/tool-todo/tests/projection.spec.ts | 2 +- .../todo/tool-todo/tests/tool-todo.spec.ts | 4 +- packages/typert/generator/package.json | 4 +- packages/typert/generator/src/analyzer.ts | 4 +- packages/typert/generator/src/invariant.ts | 2 +- .../__snapshots__/type-model.spec.ts.snap | 88 +- .../tests/cordis-catalog-contract.spec.ts | 4 +- .../tests/fixtures/type-model/cordis.d.ts | 2 +- .../type-model/packages/client/src/index.ts | 4 +- .../type-model/packages/host/src/index.ts | 6 +- .../type-model/packages/write/src/index.ts | 4 +- .../fixtures/type-model/tsconfig.base.json | 2 +- .../generator/tests/tools-catalog.spec.ts | 2 +- .../typert/generator/tests/type-model.spec.ts | 10 +- packages/typert/loader/package.json | 10 +- packages/typert/loader/src/index.ts | 6 +- packages/typert/loader/src/invariant.ts | 2 +- packages/typert/loader/tests/loader.spec.ts | 4 +- packages/typert/registry/package.json | 4 +- packages/typert/registry/src/client/index.ts | 2 +- packages/typert/registry/src/invariant.ts | 2 +- packages/typert/registry/src/service.ts | 2 +- packages/typert/registry/tests/typert.spec.ts | 2 +- packages/typert/type-meta/package.json | 4 +- packages/typert/type-meta/src/index.ts | 2 +- packages/typert/type-meta/src/invariant.ts | 2 +- packages/typert/type-meta/src/types.ts | 4 +- .../type-meta/tests/fixtures/source-launch.ts | 2 +- .../typert/type-meta/tests/type-meta.spec.ts | 2 +- packages/util/atomic-write/package.json | 4 +- packages/util/atomic-write/src/invariant.ts | 2 +- .../util/atomic-write/tests/invariant.spec.ts | 2 +- packages/util/brand/package.json | 4 +- packages/util/brand/src/invariant.ts | 2 +- packages/util/environment/README.i18n.yaml | 4 +- packages/util/environment/README.md | 2 +- packages/util/environment/README.zh.md | 2 +- packages/util/environment/package.json | 4 +- packages/util/environment/src/index.ts | 4 +- packages/util/environment/src/invariant.ts | 2 +- .../environment/tests/environment.spec.ts | 2 +- packages/util/native-command/package.json | 4 +- packages/util/native-command/src/invariant.ts | 2 +- packages/util/paths/package.json | 4 +- packages/util/paths/src/invariant.ts | 2 +- packages/util/retention/package.json | 4 +- packages/util/retention/src/invariant.ts | 2 +- packages/util/timeout/package.json | 4 +- packages/util/timeout/src/invariant.ts | 2 +- packages/web/tool-web/package.json | 6 +- packages/web/tool-web/src/fetch.ts | 2 +- packages/web/tool-web/src/index.ts | 4 +- packages/web/tool-web/src/invariant.ts | 2 +- packages/web/tool-web/src/search.ts | 2 +- .../web/tool-web/tests/integration.spec.ts | 2 +- packages/web/tool-web/tests/load-path.spec.ts | 4 +- packages/web/tool-web/tests/spill.spec.ts | 2 +- packages/web/tool-web/tests/tool-web.spec.ts | 2 +- packages/web/web-fetch-local/package.json | 6 +- packages/web/web-fetch-local/src/index.ts | 4 +- packages/web/web-fetch-local/src/invariant.ts | 2 +- .../web-fetch-local/tests/fetch-local.spec.ts | 2 +- packages/web/web-search-deepseek/package.json | 6 +- packages/web/web-search-deepseek/src/index.ts | 4 +- .../web/web-search-deepseek/src/invariant.ts | 2 +- .../tests/deepseek.spec.ts | 4 +- packages/web/web-search-exa/package.json | 6 +- packages/web/web-search-exa/src/index.ts | 4 +- packages/web/web-search-exa/src/invariant.ts | 2 +- packages/web/web-search-exa/tests/exa.spec.ts | 2 +- .../web/web-search-perplexity/package.json | 6 +- .../web/web-search-perplexity/src/index.ts | 4 +- .../web-search-perplexity/src/invariant.ts | 2 +- .../tests/perplexity.spec.ts | 2 +- packages/web/web/package.json | 6 +- packages/web/web/src/index.ts | 6 +- packages/web/web/src/invariant.ts | 2 +- packages/web/web/tests/web.spec.ts | 2 +- packages/workflow/tool-ralph/package.json | 8 +- packages/workflow/tool-ralph/src/index.ts | 4 +- packages/workflow/tool-ralph/src/invariant.ts | 2 +- .../tool-ralph/tests/integration.spec.ts | 2 +- .../tool-ralph/tests/tool-ralph.spec.ts | 4 +- packages/workflow/tool-workflow/package.json | 6 +- packages/workflow/tool-workflow/src/index.ts | 4 +- .../workflow/tool-workflow/src/invariant.ts | 2 +- .../tool-workflow/tests/tool-workflow.spec.ts | 4 +- .../workflow-workerthread/package.json | 6 +- .../workflow-workerthread/src/host.ts | 2 +- .../workflow-workerthread/src/index.ts | 4 +- .../workflow-workerthread/src/invariant.ts | 2 +- .../tests/built-worker.e2e.ts | 2 +- .../tests/integration.spec.ts | 2 +- .../tests/source-worker.compat.spec.ts | 2 +- .../tests/workflow-workerthread.e2e.ts | 2 +- .../tests/workflow-workerthread.spec.ts | 4 +- packages/workflow/workflow/package.json | 4 +- packages/workflow/workflow/src/index.ts | 4 +- packages/workflow/workflow/src/invariant.ts | 2 +- .../workflow/workflow/tests/invariant.spec.ts | 2 +- .../workflow/workflow/tests/workflow.spec.ts | 2 +- packages/workspace/workspace/package.json | 4 +- packages/workspace/workspace/src/index.ts | 4 +- packages/workspace/workspace/src/invariant.ts | 2 +- .../workspace/tests/invariant.spec.ts | 2 +- .../workspace/tests/workspace.spec.ts | 2 +- pnpm-lock.yaml | 1738 ++++++++--------- pnpm-workspace.yaml | 4 - python/sdk-runtime/package.json | 12 +- scripts/check-workspace-constraints.ts | 28 +- scripts/cordis-walk.ts | 12 +- scripts/gen-cordis-catalog-partition.spec.ts | 10 +- scripts/gen-cordis-catalog.ts | 4 +- scripts/gen-scoped-events.ts | 4 +- scripts/gen-third-party-notices.spec.ts | 8 +- scripts/gen-third-party-notices.ts | 19 +- scripts/gen-tool-catalog.ts | 2 +- scripts/publish-npm-baseline.ts | 4 +- scripts/test-invariants.spec.ts | 8 +- scripts/test-invariants.ts | 4 +- scripts/verify-cordis-config.ts | 4 +- tsconfig.base.json | 18 +- vendor/README.md | 25 +- vendor/cordis/bin.js | 6 +- vendor/cordis/package.json | 12 +- vendor/cordis/src/context.ts | 2 +- vendor/cordis/src/events.ts | 4 +- vendor/cordis/src/fiber.ts | 4 +- vendor/cordis/src/logger.ts | 2 +- vendor/cordis/src/reflect.ts | 4 +- vendor/cordis/src/registry.ts | 4 +- vendor/cordis/src/service.ts | 2 +- vendor/cordis/src/utils.ts | 2 +- vendor/cosmokit/package.json | 2 +- vendor/group/package.json | 6 +- vendor/group/src/index.ts | 2 +- vendor/hmr/package.json | 12 +- vendor/hmr/src/error.ts | 2 +- vendor/hmr/src/index.ts | 14 +- vendor/include/package.json | 8 +- vendor/include/src/index.ts | 4 +- vendor/loader/package.json | 6 +- vendor/loader/src/config/entry.ts | 4 +- vendor/loader/src/config/group.ts | 2 +- vendor/loader/src/config/isolate.ts | 4 +- vendor/loader/src/config/tree.ts | 4 +- vendor/loader/src/config/utils.ts | 2 +- vendor/loader/src/index.ts | 6 +- vendor/loader/src/internal.ts | 2 +- vendor/logger-console/package.json | 8 +- vendor/logger-console/src/browser.ts | 2 +- vendor/logger-console/src/index.ts | 2 +- vendor/logger-console/src/shared.ts | 6 +- vendor/schemastery/package.json | 4 +- vendor/schemastery/src/index.ts | 2 +- vendor/timer/package.json | 6 +- vendor/timer/src/index.ts | 4 +- 1400 files changed, 3337 insertions(+), 3317 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9daf45a832..adc14858d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, ## Conventions -- Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. +- Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages are rescoped ([mapping](docs/rescope.md)) and `private: true`. `@deepseek-ai/cordis` is a peerDependency (+ dev) of every harness package. - ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. Without a plausible relationship, an explained empty companion is correct ([package invariant rules](packages/AGENTS.md)). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fd10ebc2e0..b5b0fc5b29 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -11,19 +11,19 @@ The complete npm transitive closure, including the Landlock launcher workspace, ## Vendored source (`vendor/`) -The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed; each directory preserves its upstream `LICENSE` file. Exact upstream commits and local modifications are recorded in [`vendor/README.md`](vendor/README.md). +The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm, and republished under the `@deepseek-ai` scope. All are MIT-licensed; each directory preserves its upstream `LICENSE` file. Exact upstream commits and local modifications are recorded in [`vendor/README.md`](vendor/README.md). -| Package | Upstream | License | -| --- | --- | --- | -| `cosmokit` | [github.com/deepseek-harness/cosmokit](https://github.com/deepseek-harness/cosmokit) | MIT | -| `schemastery` | [github.com/deepseek-harness/schemastery](https://github.com/deepseek-harness/schemastery) | MIT | -| `cordis` | [github.com/cordiverse/cordis](https://github.com/cordiverse/cordis) | MIT | -| `@cordisjs/plugin-loader` | [github.com/cordiverse/cordis](https://github.com/cordiverse/cordis) | MIT | -| `@cordisjs/plugin-include` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | -| `@cordisjs/plugin-group` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | -| `@cordisjs/plugin-timer` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | -| `@cordisjs/plugin-hmr` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | -| `@cordisjs/plugin-logger-console` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | +| Package | Upstream name | Upstream | License | +| --- | --- | --- | --- | +| `@deepseek-ai/cosmokit` | `cosmokit` | [github.com/deepseek-harness/cosmokit](https://github.com/deepseek-harness/cosmokit) | MIT | +| `@deepseek-ai/schemastery` | `schemastery` | [github.com/deepseek-harness/schemastery](https://github.com/deepseek-harness/schemastery) | MIT | +| `@deepseek-ai/cordis` | `cordis` | [github.com/cordiverse/cordis](https://github.com/cordiverse/cordis) | MIT | +| `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | [github.com/cordiverse/cordis](https://github.com/cordiverse/cordis) | MIT | +| `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | +| `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | +| `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | +| `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | +| `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | ## Runtime npm dependencies diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 9ca8ab91bf..77d8143d53 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -8,9 +8,9 @@ The dsh-base bundle patch every profile applies first; mode bundles (dsh-web-app ```mermaid flowchart LR cfg["packages/bundle/base/cordis.patch.yml<br/>cordis.yml"] - plugin_dsh_base_timer["timer<br/>@cordisjs/plugin-timer"] + plugin_dsh_base_timer["timer<br/>@deepseek-ai/cordis-plugin-timer"] cfg --> plugin_dsh_base_timer - plugin_dsh_base_hmr["hmr<br/>@cordisjs/plugin-hmr"] + plugin_dsh_base_hmr["hmr<br/>@deepseek-ai/cordis-plugin-hmr"] cfg --> plugin_dsh_base_hmr plugin_dsh_base_llm["llm<br/>@deepseek-ai/dsh-llm"] cfg --> plugin_dsh_base_llm @@ -164,8 +164,8 @@ flowchart LR | Plugin id | Package / module | | --- | --- | -| `timer` | `@cordisjs/plugin-timer` | -| `hmr` | `@cordisjs/plugin-hmr` | +| `timer` | `@deepseek-ai/cordis-plugin-timer` | +| `hmr` | `@deepseek-ai/cordis-plugin-hmr` | | `llm` | `@deepseek-ai/dsh-llm` | | `session` | `@deepseek-ai/dsh-session` | | `typert` | `@deepseek-ai/dsh-typert-registry` | diff --git a/apps/cli/package.json b/apps/cli/package.json index 03bd4c3db9..a2695f59c9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -13,10 +13,10 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@cordisjs/plugin-hmr": "workspace:*", - "@cordisjs/plugin-include": "workspace:*", - "@cordisjs/plugin-loader": "workspace:*", - "@cordisjs/plugin-timer": "workspace:*", + "@deepseek-ai/cordis-plugin-hmr": "workspace:*", + "@deepseek-ai/cordis-plugin-include": "workspace:*", + "@deepseek-ai/cordis-plugin-loader": "workspace:*", + "@deepseek-ai/cordis-plugin-timer": "workspace:*", "@deepseek-ai/dsh-agent-tool-mode": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-base": "workspace:^", @@ -63,7 +63,7 @@ "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "commander": "^15.0.0", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "js-yaml": "^4.2.0", "node-addon-require-builtin": "^0.1.4" }, diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index e4a719379e..8c37af124a 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -10,8 +10,8 @@ import { writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { FiberState, type Context } from 'cordis' -import type { PatchOptions } from '@cordisjs/plugin-include' +import { FiberState, type Context } from '@deepseek-ai/cordis' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { dshHomePath } from '@deepseek-ai/dsh-paths' import { boot, @@ -308,9 +308,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // bare custom profile may not mount either. if (ctx.get('hmr') === undefined) { if (ctx.get('timer') === undefined) { - await ctx.loader.create({ name: '@cordisjs/plugin-timer' }) + await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' }) } - await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } }) + await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } }) } await watchUserPatches(ctx, { binName: NAME, diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index e438c7da7c..8d056d100b 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -10,8 +10,8 @@ import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' -import type { Context } from 'cordis' -import type { PatchOptions } from '@cordisjs/plugin-include' +import type { Context } from '@deepseek-ai/cordis' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { runProfile, type ProfileRows } from './profile-boot.ts' diff --git a/apps/cli/src/windows-shell.ts b/apps/cli/src/windows-shell.ts index fbb3d13194..1a9ca719f8 100644 --- a/apps/cli/src/windows-shell.ts +++ b/apps/cli/src/windows-shell.ts @@ -11,7 +11,7 @@ */ import { join } from 'node:path' -import type { PatchOptions } from '@cordisjs/plugin-include' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot' /** The base bundle whose package carries the Windows shell patch. */ diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 603d8e2dd5..e30739214d 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -44,8 +44,8 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'plugin', profile: 'tui', args: ['add', 'turtle-ui'] }) expect(parse(['plugin', '--profile', 'tui', 'remove', 'turtle-ui'])) .toEqual({ mode: 'plugin', profile: 'tui', args: ['remove', 'turtle-ui'] }) - expect(parse(['plugin', '--profile', 'tui', 'why', 'cordis'])) - .toEqual({ mode: 'plugin', profile: 'tui', args: ['why', 'cordis'] }) + expect(parse(['plugin', '--profile', 'tui', 'why', '@deepseek-ai/cordis'])) + .toEqual({ mode: 'plugin', profile: 'tui', args: ['why', '@deepseek-ai/cordis'] }) // Unknown pnpm flags forward verbatim. expect(parse(['plugin', '--profile', 'tui', 'add', '--save-dev', 'x'])) .toEqual({ mode: 'plugin', profile: 'tui', args: ['add', '--save-dev', 'x'] }) diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index 8d6019ab53..98f3fd7578 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -1,5 +1,5 @@ import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' diff --git a/apps/cli/tests/fixtures/never-dispose.mjs b/apps/cli/tests/fixtures/never-dispose.mjs index 5dc8510550..9e81b32738 100644 --- a/apps/cli/tests/fixtures/never-dispose.mjs +++ b/apps/cli/tests/fixtures/never-dispose.mjs @@ -4,7 +4,7 @@ import { existsSync } from 'node:fs' /** * Register a disposer that keeps process shutdown pending until it is forced. - * @param {import('cordis').Context} ctx - loader-mounted test plugin context. + * @param {import('@deepseek-ai/cordis').Context} ctx - loader-mounted test plugin context. */ export function apply(ctx) { const keepAlive = setInterval(() => {}, 60_000) diff --git a/apps/cli/tests/memory-mcp-configs.spec.ts b/apps/cli/tests/memory-mcp-configs.spec.ts index 818b8f4561..ee8905c3b6 100644 --- a/apps/cli/tests/memory-mcp-configs.spec.ts +++ b/apps/cli/tests/memory-mcp-configs.spec.ts @@ -8,8 +8,8 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' -import type { PatchOptions } from '@cordisjs/plugin-include' +import type { Context } from '@deepseek-ai/cordis' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 004de203c8..27e88a1738 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -3,11 +3,11 @@ import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { PatchOptions } from '@cordisjs/plugin-include' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { beforeAll, describe, expect, it } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' diff --git a/apps/web/package.json b/apps/web/package.json index c58e5b9682..6f4bce8dc2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,7 +23,7 @@ "react-dom": "^18.2.0" }, "devDependencies": { - "@cordisjs/plugin-group": "workspace:^", + "@deepseek-ai/cordis-plugin-group": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index cafd0fb474..57a3867187 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -6,8 +6,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { chromium } from 'playwright' import { expect, it } from 'vitest' -import { Context } from 'cordis' -import type { Fiber } from 'cordis' +import { Context } from '@deepseek-ai/cordis' +import type { Fiber } from '@deepseek-ai/cordis' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { REPO_ROOT } from './support.ts' diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 945775678f..0104a7bee7 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -29,10 +29,10 @@ import { join } from 'node:path' import { pathToFileURL } from 'node:url' import type { Page } from 'playwright' import { expect } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include, { type PatchOptions } from '@cordisjs/plugin-include' -import Group from '@cordisjs/plugin-group' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import Group from '@deepseek-ai/cordis-plugin-group' import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot' import { addHarnessSourceSection, @@ -464,7 +464,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We // `cordis:group` beside it, exactly as `boot()` registers it: a group row is // how a preset gives one `isolate` realm to a provider and its consumers, // and a preset resolving package names from its own directory cannot reach - // `@cordisjs/plugin-group` by name. + // `@deepseek-ai/cordis-plugin-group` by name. ctx.loader.builtins.group = Group // The shipped CLI deliberately has no dependency on this opt-in package. // Keep the Loader row real without broadening the product installation. diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 09204ccfff..ba4cbd5f55 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 81cd80893d53212edc74cc85e3e05731fa05f411 -api-gateway.zh.md: 692cf825f619f71e86ae801e04246e9feb4a4c36 +api-gateway.md: 06f98546c8858af08efcb07c007d8e1b95b90c19 +api-gateway.zh.md: 82bf8923c45151d0c00e71848be89806427d1ce3 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 81cd80893d..06f98546c8 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -17,7 +17,7 @@ Services normally extend `GatewayService` so the constructor explicitly binds th ```ts import type { Agent } from '@deepseek-ai/dsh-agent' import { GatewayService, Remote, RemoteScope } from '@deepseek-ai/dsh-type-meta' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export interface CreateGoalRequest { objective: string @@ -60,7 +60,7 @@ The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. ```ts ignore-check import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' export const inject = ['remote', 'remote.goals'] diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 692cf825f6..82bf8923c4 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -17,7 +17,7 @@ Service 通常继承 `GatewayService`,让 Cordis service key 与默认 Remote ```ts import type { Agent } from '@deepseek-ai/dsh-agent' import { GatewayService, Remote, RemoteScope } from '@deepseek-ai/dsh-type-meta' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export interface CreateGoalRequest { objective: string @@ -60,7 +60,7 @@ Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直 ```ts ignore-check import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' export const inject = ['remote', 'remote.goals'] diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 5bdff5df21..0dcfdafacd 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md -adding-a-package.md: dcd5fa66f3616c2c22930babd09cb3edab38e182 -adding-a-package.zh.md: c8769197e0b1db31348b7f2442dbcd636bf43cb2 +adding-a-package.md: e108b9e88e0f0e470f96173306af79c69ff695fb +adding-a-package.zh.md: 97b4df150fda7010fba048d7acb5e23a87519911 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index dcd5fa66f3..e108b9e88e 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -22,7 +22,7 @@ packages/<group>/<pkg>/ Choose an existing group when one matches the package's role (`core`, `llm`, `bash`, `compact`, `subagent`, `todo`, `session-persistence`, `ui`, `util`, or `support`). A new group is allowed, but it is a pure container: no `package.json`, no source files, and packages still sit exactly one level below it. -package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, a `version` matching the root `package.json`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list contains exactly `lib/index.js`, `lib/invariant.js`, `lib/types/**/*.d.ts`, and package-specific runtime artifacts recognized by the gate; a package whose runtime export points into the emitted tree also includes `lib/types/**/*.js`. Do not publish `src`, declaration maps, JS maps, or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. +package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, a `version` matching the root `package.json`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `@deepseek-ai/cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `@deepseek-ai/schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list contains exactly `lib/index.js`, `lib/invariant.js`, `lib/types/**/*.d.ts`, and package-specific runtime artifacts recognized by the gate; a package whose runtime export points into the emitted tree also includes `lib/types/**/*.js`. Do not publish `src`, declaration maps, JS maps, or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. In-package relative imports use explicit `.ts` specifiers in source (for example, `export * from './types.ts'`). The compiler rewrites those to `.js` in emitted JS and leaves explicit `.ts` specifiers in declarations, which standard NodeNext/Node16 TypeScript consumers resolve to the sibling `.d.ts` files. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index c8769197e0..97b4df150f 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -22,7 +22,7 @@ packages/<group>/<pkg>/ 当已有分组与包的角色匹配时,选择该分组(`core`、`llm`、`bash`、`compact`、`subagent`、`todo`、`session-persistence`、`ui`、`util` 或 `support`)。允许新建分组,但分组只是纯容器:没有 `package.json`,没有源文件,包仍然恰好位于其下一层。 -package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-constraints.ts` 强制执行):`private: true`,`version` 与根 `package.json` 一致,`type: module`,`main: "lib/index.js"`,`types: "lib/types/index.d.ts"`,`exports["."].types: "./lib/types/index.d.ts"`,`exports["."].default: "./lib/index.js"`,`cordis` 同时出现在 peerDependencies 和 devDependencies 中(相同范围)。每个 dsh 对等依赖(peer dependency)都要在 devDependencies 中镜像。`schemastery` 放在 `dependencies` 中(它是运行时校验器),与 agent-loop 保持一致。`files` 列表精确包含 `lib/index.js`、`lib/invariant.js`、`lib/types/**/*.d.ts` 以及门禁认可的包专用运行时产物;如果包的运行时 export 指向输出树,还要包含 `lib/types/**/*.js`。不要发布 `src`、声明映射、JS map 或陈旧的根声明文件。带有 `bin` 的 CLI 应用包在 `files` 中将 `lib/bin.js` 紧跟在 `lib/index.js` 之后。 +package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-constraints.ts` 强制执行):`private: true`,`version` 与根 `package.json` 一致,`type: module`,`main: "lib/index.js"`,`types: "lib/types/index.d.ts"`,`exports["."].types: "./lib/types/index.d.ts"`,`exports["."].default: "./lib/index.js"`,`@deepseek-ai/cordis` 同时出现在 peerDependencies 和 devDependencies 中(相同范围)。每个 dsh 对等依赖(peer dependency)都要在 devDependencies 中镜像。`@deepseek-ai/schemastery` 放在 `dependencies` 中(它是运行时校验器),与 agent-loop 保持一致。`files` 列表精确包含 `lib/index.js`、`lib/invariant.js`、`lib/types/**/*.d.ts` 以及门禁认可的包专用运行时产物;如果包的运行时 export 指向输出树,还要包含 `lib/types/**/*.js`。不要发布 `src`、声明映射、JS map 或陈旧的根声明文件。带有 `bin` 的 CLI 应用包在 `files` 中将 `lib/bin.js` 紧跟在 `lib/index.js` 之后。 包内的相对导入在源码中使用显式 `.ts` 后缀(例如 `export * from './types.ts'`)。编译器在输出的 JS 中将其重写为 `.js`,在声明文件中保留显式 `.ts` 后缀;标准的 NodeNext/Node16 TypeScript 消费方会将其解析到同目录的 `.d.ts` 文件。 diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index e232ad4302..5b47beab61 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-tool.md -adding-a-tool.md: b030d3c3a6b7dd66b6594779345a96af3a895bd8 -adding-a-tool.zh.md: 4272a7a4571782bc213ca29de1a57d51fbd24075 +adding-a-tool.md: fa39c4b97f3c0eb739ea34d1b43ef46d11285bbf +adding-a-tool.zh.md: ab32e90fac5539ee403a36b2dd52e60db3ad603c diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index b030d3c3a6..fa39c4b97f 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -8,7 +8,7 @@ Reference for the contracts a model-facing tool must satisfy. For an ordered fir ```ts import { readFile } from 'node:fs/promises' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'my-tool' diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 4272a7a457..ab32e90fac 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -8,7 +8,7 @@ ```ts import { readFile } from 'node:fs/promises' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'my-tool' diff --git a/docs/cookbook/adding-a-vendored-package.i18n.yaml b/docs/cookbook/adding-a-vendored-package.i18n.yaml index 4f5b8c3c49..b17f3390cf 100644 --- a/docs/cookbook/adding-a-vendored-package.i18n.yaml +++ b/docs/cookbook/adding-a-vendored-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-vendored-package.md -adding-a-vendored-package.md: 724d89c1c7cd728f7123a6975b5500cd40815851 -adding-a-vendored-package.zh.md: d16ec1056431a4ac1c02d50a5ef0f0a64b67ca6d +adding-a-vendored-package.md: 239ac27565204332559038014fabae83fc2d1057 +adding-a-vendored-package.zh.md: 2bf5c7eeaffec594dbc4d13e0aa33b12a9eccf4f diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index 724d89c1c7..239ac27565 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -8,7 +8,7 @@ When the harness needs another upstream Cordis package (e.g. `@cordisjs/plugin-h ``` vendor/<dir>/ - package.json # from upstream; set "private": true, keep name/exports/type + package.json # from upstream; set "private": true, rescope the name, keep exports/type tsconfig.json # extends ../../tsconfig.base.json (see configuration below) src/ # the upstream src/ verbatim README.md LICENSE # if upstream ships them @@ -29,7 +29,7 @@ vendor/<dir>/ } ``` -`package.json` invariants: `"private": true` (vendored packages are never published), keep upstream's `name`/`version`/`exports`/`type`, point declaration metadata at `lib/types`, publish `.d.ts` and `.d.ts.map` declaration outputs, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). +`package.json` invariants: `"private": true` (vendored packages are never published), rescope the `name` ([mapping](../rescope.md)) while keeping upstream's `version`/`exports`/`type`, point declaration metadata at `lib/types`, publish `.d.ts` and `.d.ts.map` declaration outputs, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). Local relative imports/exports in vendored TypeScript source use explicit `.ts` specifiers after copying. This is a repo-local build difference from upstream: `rewriteRelativeImportExtensions` emits `.js` runtime imports while declarations keep explicit `.ts` specifiers that NodeNext/Node16 TypeScript consumers can resolve. diff --git a/docs/cookbook/adding-a-vendored-package.zh.md b/docs/cookbook/adding-a-vendored-package.zh.md index d16ec10564..2bf5c7eeaf 100644 --- a/docs/cookbook/adding-a-vendored-package.zh.md +++ b/docs/cookbook/adding-a-vendored-package.zh.md @@ -8,7 +8,7 @@ ``` vendor/<dir>/ - package.json # from upstream; set "private": true, keep name/exports/type + package.json # from upstream; set "private": true, rescope the name, keep exports/type tsconfig.json # extends ../../tsconfig.base.json (see configuration below) src/ # the upstream src/ verbatim README.md LICENSE # if upstream ships them @@ -29,7 +29,7 @@ vendor/<dir>/ } ``` -`package.json` 的不变式:`"private": true`(vendored 包永不发布);保留上游的 `name`/`version`/`exports`/`type`;声明元数据指向 `lib/types`;发布 `.d.ts` 与 `.d.ts.map` 声明输出;在 `peerDependencies` 中列出其 Cordis 依赖(与上游 manifest(元数据清单)一致)。传递性上游依赖本身也必须被 vendor 或已存在于仓库中——vendor 一个包往往意味着 vendor 其整条依赖树(如 `@cordisjs/plugin-http` 会拉入 `@cordisjs/fetch-file`)。 +`package.json` 的不变式:`"private": true`(vendored 包永不发布);改写 `name` 的 scope([映射](../rescope.md)),保留上游的 `version`/`exports`/`type`;声明元数据指向 `lib/types`;发布 `.d.ts` 与 `.d.ts.map` 声明输出;在 `peerDependencies` 中列出其 Cordis 依赖(与上游 manifest(元数据清单)一致)。传递性上游依赖本身也必须被 vendor 或已存在于仓库中——vendor 一个包往往意味着 vendor 其整条依赖树(如 `@cordisjs/plugin-http` 会拉入 `@cordisjs/fetch-file`)。 vendored TypeScript 源码中的本地相对导入/导出在复制后使用显式 `.ts` 后缀。这是仓库本地构建与上游的差异:`rewriteRelativeImportExtensions` 输出 `.js` 运行时导入,而声明文件保留显式 `.ts` 后缀,使 NodeNext/Node16 的 TypeScript 消费方能够解析。 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index ac600e8c6d..3a98437564 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: 95ba269a5d62e14cfde487d5a3aaca5db493657e -extension-cookbook.zh.md: e3fbe09f1ec09568e3b259aee361d33ba3e62140 +extension-cookbook.md: f292075dfdad5016d81521318b38594e3d7ee8b4 +extension-cookbook.zh.md: 0623c49d9d7075b3823c1fd340b36a5fba21f31e diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 95ba269a5d..f292075dfd 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -13,7 +13,7 @@ A tool registers on `ctx.tools`. The annotated `defineTool` example (typed `exec This permission gate is one example of a hook plugin. It returns a typed decision from the `tools/pre-execute` gate to allow or deny a call; sandbox, permission, and plan-mode plugins can use this extension point. Hook plugins can intercept other extension points and are not inherently permission gates. A "native hook" is an ordinary Cordis plugin on an interception point; it needs no external protocol. ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' declare function isAllowed(exec: ToolExecution): Promise<boolean> @@ -37,7 +37,7 @@ This waterfall is the reorderable policy layer. Use `ctx.tools.guard()` when an A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`. A browser plugin contributing a business row to the built-in Web Client instead registers a `ConversationNodeDefinition` and keyed Chat renderer; follow the [Conversation Node guide](adding-a-conversation-node.md). ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' @@ -67,7 +67,7 @@ A *protocol driver* adapts a wire peer to `ctx.agents`; it may serve a UI or an [`packages/acp/acp`](../../packages/acp/acp) is the automation-only worked example: it exposes fresh text sessions over Agent Client Protocol JSON-RPC stdio, emits committed assistant text, and registers a one-shot machine permission answerer for agents it owns. Its [README](../../packages/acp/acp/README.md) defines the exact methods, event order, and lifecycle contract. ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'my-protocol-bridge' export const inject = ['agents', 'sessions', 'sessionPersistence'] diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index e3fbe09f1e..0623c49d9d 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -13,7 +13,7 @@ harness 扩展的参考模式。代码片段省略了 import 和辅助实现, 这个权限门禁是钩子插件的一个示例。它从 `tools/pre-execute` 门禁返回一个类型化的决策,用于允许或拒绝一次调用;沙箱、权限和 plan-mode 插件都可以使用该扩展点。钩子插件也可以拦截其他扩展点,本身并不等同于权限门禁。「原生钩子」是在拦截点上运行的普通 Cordis 插件,不需要外部协议。 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' declare function isAllowed(exec: ToolExecution): Promise<boolean> @@ -37,7 +37,7 @@ export function apply(ctx: Context) { UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。如果浏览器插件要向内建 Web Client 贡献业务行,则应注册 `ConversationNodeDefinition` 与 keyed Chat renderer;具体步骤见 [Conversation Node 指南](adding-a-conversation-node.md)。 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' @@ -67,7 +67,7 @@ export function apply(ctx: Context) { [`packages/acp/acp`](../../packages/acp/acp) 是仅面向自动化的完整示例:它通过 ACP(Agent Client Protocol)JSON-RPC stdio 提供全新文本会话,发出已提交的助手文本,并为其拥有的 agent 注册一次性机器权限应答器。其 [README](../../packages/acp/acp/README.md) 定义确切的方法、事件顺序和生命周期约定。 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'my-protocol-bridge' export const inject = ['agents', 'sessions', 'sessionPersistence'] diff --git a/docs/cordis-primer.i18n.yaml b/docs/cordis-primer.i18n.yaml index 12177e3d35..ad9cfe716e 100644 --- a/docs/cordis-primer.i18n.yaml +++ b/docs/cordis-primer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-primer.md -cordis-primer.md: c95909a4a1deab9407efedbb990ef13be6e43a16 -cordis-primer.zh.md: a18b8b37af19a610b71babbe5e67f96bb09e81b1 +cordis-primer.md: 93725949a9490f757edebcf3e8391db9e73321b1 +cordis-primer.zh.md: fd2a327b526b210986bc1574013fca2c0cec5dda diff --git a/docs/cordis-primer.md b/docs/cordis-primer.md index c95909a4a1..93725949a9 100644 --- a/docs/cordis-primer.md +++ b/docs/cordis-primer.md @@ -35,7 +35,7 @@ For single-decision events, short-circuiting is the design. A policy listener ca ## Loader Configuration -`@cordisjs/plugin-include` parses `!!js` into expression nodes, but the Loader interpolates only an entry's `config` before mounting the plugin. Entry metadata (`id`, `name`, `group`, `disabled`, `inject`, `intercept`, and `isolate`) remains literal; `disabled: !!js ...` is therefore a truthy object that always disables the entry. Use explicit config overlays when environment selection changes which plugins are mounted. +`@deepseek-ai/cordis-plugin-include` parses `!!js` into expression nodes, but the Loader interpolates only an entry's `config` before mounting the plugin. Entry metadata (`id`, `name`, `group`, `disabled`, `inject`, `intercept`, and `isolate`) remains literal; `disabled: !!js ...` is therefore a truthy object that always disables the entry. Use explicit config overlays when environment selection changes which plugins are mounted. ## Practical Rules diff --git a/docs/cordis-primer.zh.md b/docs/cordis-primer.zh.md index a18b8b37af..fd2a327b52 100644 --- a/docs/cordis-primer.zh.md +++ b/docs/cordis-primer.zh.md @@ -39,7 +39,7 @@ Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。 ## Loader 配置 -`@cordisjs/plugin-include` 将 `!!js` 解析为表达式节点,但 Loader 仅在挂载插件前对条目的 `config` 做插值。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept` 和 `isolate`)保持字面值;因此 `disabled: !!js ...` 是一个 truthy 对象,会始终禁用该条目。需要根据环境选择挂载哪些插件时,请使用显式的配置覆盖层。 +`@deepseek-ai/cordis-plugin-include` 将 `!!js` 解析为表达式节点,但 Loader 仅在挂载插件前对条目的 `config` 做插值。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept` 和 `isolate`)保持字面值;因此 `disabled: !!js ...` 是一个 truthy 对象,会始终禁用该条目。需要根据环境选择挂载哪些插件时,请使用显式的配置覆盖层。 ## 实践规则 diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml index 9bf649ab29..2ac502e501 100644 --- a/docs/cordis-tutorial/01-first-plugin.i18n.yaml +++ b/docs/cordis-tutorial/01-first-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/01-first-plugin.md -01-first-plugin.md: 260026329443f9a5b8860d11a6527dbd687eb44c -01-first-plugin.zh.md: 69dedb898c7ea29f99233f07126cd413fa0ddbe2 +01-first-plugin.md: 448034b54107d5620673052ad388feecc22fe1e1 +01-first-plugin.zh.md: a1838be61f8099831d59afa266fe40ce6bd165bf diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md index 2600263294..448034b541 100644 --- a/docs/cordis-tutorial/01-first-plugin.md +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -9,7 +9,7 @@ In the loader configuration used here, a Cordis plugin module named-exports an ` In your `tmp/cordis-tutorial` directory (see [setup](index.md#setup)), create `hello.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'hello' @@ -55,7 +55,7 @@ There is no framework bootstrap code in your file: a plugin describes what it co A function is the most common form, but Cordis accepts three: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' // 1. Function plugin (what you just wrote). export function apply(ctx: Context) {} diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md index 69dedb898c..a1838be61f 100644 --- a/docs/cordis-tutorial/01-first-plugin.zh.md +++ b/docs/cordis-tutorial/01-first-plugin.zh.md @@ -9,7 +9,7 @@ 在 `tmp/cordis-tutorial` 目录中(参见[环境设置](index.md#setup))创建 `hello.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'hello' @@ -55,7 +55,7 @@ hello from my first plugin 函数是最常见的形式,但 Cordis 接受三种形式: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' // 1. Function plugin (what you just wrote). export function apply(ctx: Context) {} diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml index 12793267e2..f97516c457 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/02-lifecycle-and-effects.md -02-lifecycle-and-effects.md: 7b195b63a1e8730f27b9dd9af8af6a68a588cee9 -02-lifecycle-and-effects.zh.md: 4a3f83dedd5c95c7fcb5c1aebbbb8cb2e849b9cf +02-lifecycle-and-effects.md: 3e88c6f1e1fb1bc825fb74434520993c855010c3 +02-lifecycle-and-effects.zh.md: 3cceecfb8334f2ed2ec9942fa876a0e51e1b315f diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.md b/docs/cordis-tutorial/02-lifecycle-and-effects.md index 7b195b63a1..3e88c6f1e1 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.md @@ -11,7 +11,7 @@ For a resource Cordis does not already manage — a timer, a connection, a watch Create `lifecycle.ts` in `tmp/cordis-tutorial`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'lifecycle-demo' diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md index 4a3f83dedd..3cceecfb83 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md @@ -11,7 +11,7 @@ Cordis 插件可能因修改配置、热重载、显式资源释放或所需服 创建 `lifecycle.ts`,将它放在 `tmp/cordis-tutorial` 中: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'lifecycle-demo' diff --git a/docs/cordis-tutorial/03-services.i18n.yaml b/docs/cordis-tutorial/03-services.i18n.yaml index bdb7e19387..0d5f9dfccb 100644 --- a/docs/cordis-tutorial/03-services.i18n.yaml +++ b/docs/cordis-tutorial/03-services.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/03-services.md -03-services.md: 82b08b7b8a2ec8a6b340dd1fdc7fa3de98cedff9 -03-services.zh.md: ba4152454eb79a21b183b867c0ba2ef32cd43923 +03-services.md: 3f2273ba4061912640e409d7b4deb4cd1b45684f +03-services.zh.md: 657271aba6c0b2e824c79fa822e29c8a6dcf4275 diff --git a/docs/cordis-tutorial/03-services.md b/docs/cordis-tutorial/03-services.md index 82b08b7b8a..3f2273ba40 100644 --- a/docs/cordis-tutorial/03-services.md +++ b/docs/cordis-tutorial/03-services.md @@ -9,9 +9,9 @@ A **service** is a named capability one plugin provides and other plugins consum Create `greeter.ts` in `tmp/cordis-tutorial`: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { greeter: GreeterService } @@ -37,7 +37,7 @@ export function apply(ctx: Context) { Two pieces work together: - **Runtime**: `super(ctx, 'greeter')` registers the instance under the name `greeter`. From then on, any plugin can reach it as `ctx.greeter`. The registration is an effect — unloading the provider removes the service. -- **Compile time**: the `declare module 'cordis'` block is TypeScript declaration merging. It adds `greeter` to the `Context` interface so `ctx.greeter` typechecks everywhere. It generates no code; without it the service still works at runtime, but consumers lose type safety. +- **Compile time**: the `declare module '@deepseek-ai/cordis'` block is TypeScript declaration merging. It adds `greeter` to the `Context` interface so `ctx.greeter` typechecks everywhere. It generates no code; without it the service still works at runtime, but consumers lose type safety. A `Service` subclass is itself a plugin (the class form from chapter 1), so `ctx.plugin(GreeterService)` mounts it like any other. @@ -46,7 +46,7 @@ A `Service` subclass is itself a plugin (the class form from chapter 1), so `ctx Create `consumer.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'consumer' export const inject = ['greeter'] diff --git a/docs/cordis-tutorial/03-services.zh.md b/docs/cordis-tutorial/03-services.zh.md index ba4152454e..657271aba6 100644 --- a/docs/cordis-tutorial/03-services.zh.md +++ b/docs/cordis-tutorial/03-services.zh.md @@ -9,9 +9,9 @@ 创建 `greeter.ts`,将它放在 `tmp/cordis-tutorial` 中: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { greeter: GreeterService } @@ -37,7 +37,7 @@ export function apply(ctx: Context) { 两部分协同工作: - **运行时**:`super(ctx, 'greeter')` 以名称 `greeter` 注册该实例。此后,任何插件都可以通过 `ctx.greeter` 访问它。注册属于 effect,卸载提供方时会移除该服务。 -- **编译时**:`declare module 'cordis'` 块使用 TypeScript 声明合并,把 `greeter` 加入 `Context` 接口,使 `ctx.greeter` 在各处都能通过类型检查。它不会生成代码;没有该声明时,服务在运行时仍能工作,但消费方会失去类型安全。 +- **编译时**:`declare module '@deepseek-ai/cordis'` 块使用 TypeScript 声明合并,把 `greeter` 加入 `Context` 接口,使 `ctx.greeter` 在各处都能通过类型检查。它不会生成代码;没有该声明时,服务在运行时仍能工作,但消费方会失去类型安全。 `Service` 子类本身就是插件(第 1 章介绍的类形态),因此 `ctx.plugin(GreeterService)` 会像挂载其他插件一样挂载它。 @@ -46,7 +46,7 @@ export function apply(ctx: Context) { 创建 `consumer.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'consumer' export const inject = ['greeter'] diff --git a/docs/cordis-tutorial/04-events.i18n.yaml b/docs/cordis-tutorial/04-events.i18n.yaml index b453ffb1f6..0f86242ecd 100644 --- a/docs/cordis-tutorial/04-events.i18n.yaml +++ b/docs/cordis-tutorial/04-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/04-events.md -04-events.md: e77641dffcb82fcb50a24ca2e3e764d152218094 -04-events.zh.md: 00cce854e9a54fddb594ffa8e306f60a725ac012 +04-events.md: 0302adf7c81b802b406f5f6737539ccf3eb970f9 +04-events.zh.md: ae41e757c06a46ef70c250c94a124546769e0112 diff --git a/docs/cordis-tutorial/04-events.md b/docs/cordis-tutorial/04-events.md index e77641dffc..0302adf7c8 100644 --- a/docs/cordis-tutorial/04-events.md +++ b/docs/cordis-tutorial/04-events.md @@ -9,9 +9,9 @@ Services support direct calls; **events** let a plugin announce something withou Create `stats.ts` in `tmp/cordis-tutorial` — a service that counts things and announces each change: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { stats: StatsService } @@ -46,7 +46,7 @@ The `interface Events` merge is the event-system twin of the `interface Context` Create `reporter.ts`: ```ts ignore-check -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from './stats.ts' export const name = 'reporter' @@ -96,9 +96,9 @@ Every harness event documents its mode in the generated reference on its owning Waterfall is the mode that powers interception. Each listener receives the arguments plus a `next()` continuation; it can transform what `next()` returns, or return without calling `next()` and short-circuit the rest of the chain — what the Cordis docs call the veto. Create `waterfall-demo.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { 'demo/transform'(input: string, next: () => Promise<string>): Promise<string> } diff --git a/docs/cordis-tutorial/04-events.zh.md b/docs/cordis-tutorial/04-events.zh.md index 00cce854e9..ae41e757c0 100644 --- a/docs/cordis-tutorial/04-events.zh.md +++ b/docs/cordis-tutorial/04-events.zh.md @@ -9,9 +9,9 @@ 创建 `stats.ts`,将它放在 `tmp/cordis-tutorial` 中。它是一项负责计数并在每次变化时发出通知的服务: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { stats: StatsService } @@ -46,7 +46,7 @@ export function apply(ctx: Context) { 创建 `reporter.ts`: ```ts ignore-check -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from './stats.ts' export const name = 'reporter' @@ -96,9 +96,9 @@ export function apply(ctx: Context) { waterfall 是实现拦截的模式。每个监听器都会收到参数和一个 `next()` continuation;它可以转换 `next()` 的返回值,也可以不调用 `next()` 就直接返回,从而短路链条的其余部分。Cordis 文档把后一种行为称为否决。创建 `waterfall-demo.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { 'demo/transform'(input: string, next: () => Promise<string>): Promise<string> } diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml index 7db45165c4..08493c67af 100644 --- a/docs/cordis-tutorial/05-config.i18n.yaml +++ b/docs/cordis-tutorial/05-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/05-config.md -05-config.md: 834bb140cc1ff976acc8f21c8f54a7fb02636eac -05-config.zh.md: f5cc6ac1ca4fa02eba6a1b015b9f6ae3b1a925fc +05-config.md: ad73a732242e4015b2976e6fb193ff464e148dfa +05-config.zh.md: edb3c4113288dfa80e899fb2e5eb21a67d929ca6 diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md index 834bb140cc..ad73a73224 100644 --- a/docs/cordis-tutorial/05-config.md +++ b/docs/cordis-tutorial/05-config.md @@ -9,8 +9,8 @@ Each `cordis.yml` entry can carry a `config` block, and the plugin declares a sc Create `config-demo.ts` in `tmp/cordis-tutorial`: ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' export const name = 'config-demo' diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md index f5cc6ac1ca..edb3c41132 100644 --- a/docs/cordis-tutorial/05-config.zh.md +++ b/docs/cordis-tutorial/05-config.zh.md @@ -9,8 +9,8 @@ 创建 `config-demo.ts`,并将其放在 `tmp/cordis-tutorial` 中: ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' export const name = 'config-demo' diff --git a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml index 3732651e58..a75d53e7dd 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml +++ b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/06-composition-and-hmr.md -06-composition-and-hmr.md: a169d7a164be63c939e352e4e5b0bf9bce43da29 -06-composition-and-hmr.zh.md: 07ae46555c390d625a4397933e2ec5ac059bd270 +06-composition-and-hmr.md: 490e3de3a98dd823190deefd47e1b6f2f8ba71b5 +06-composition-and-hmr.zh.md: 4aefb5ecd036929590ab373a2883d90779997b4b diff --git a/docs/cordis-tutorial/06-composition-and-hmr.md b/docs/cordis-tutorial/06-composition-and-hmr.md index a169d7a164..490e3de3a9 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.md @@ -22,24 +22,24 @@ Groups nest a sub-list of entries that load and unload as one unit, and `isolate ## Hot module replacement -Because unloading releases effects ([chapter 2](02-lifecycle-and-effects.md)) and loading follows dependencies ([chapter 3](03-services.md)), HMR can replace a running plugin by unloading and loading it. The `@cordisjs/plugin-hmr` plugin watches your files and does exactly that on save. +Because unloading releases effects ([chapter 2](02-lifecycle-and-effects.md)) and loading follows dependencies ([chapter 3](03-services.md)), HMR can replace a running plugin by unloading and loading it. The `@deepseek-ai/cordis-plugin-hmr` plugin watches your files and does exactly that on save. In `tmp/cordis-tutorial`, write `cordis.yml`: ```yaml - id: logger - name: '@cordisjs/plugin-logger-console' + name: '@deepseek-ai/cordis-plugin-logger-console' - id: timer - name: '@cordisjs/plugin-timer' + name: '@deepseek-ai/cordis-plugin-timer' - id: hmr - name: '@cordisjs/plugin-hmr' + name: '@deepseek-ai/cordis-plugin-hmr' config: root: ['.'] - id: hello name: './hello.ts' ``` -Two support plugins joined the list: HMR logs through the Cordis logger service, so without a console exporter you would not see its messages, and it `inject`s the `timer` service for debouncing — without `@cordisjs/plugin-timer` it sits in PENDING forever, silently. That silence is the subject of the next section. +Two support plugins joined the list: HMR logs through the Cordis logger service, so without a console exporter you would not see its messages, and it `inject`s the `timer` service for debouncing — without `@deepseek-ai/cordis-plugin-timer` it sits in PENDING forever, silently. That silence is the subject of the next section. HMR reads Node's loader internals through the Loader's native helper. Run Cordis under tsx: @@ -65,7 +65,7 @@ The flip side of dependency-driven loading: a plugin whose `inject` names a serv You can see the states directly. Every context can enumerate the plugin registry; create `diagnose.ts`: ```ts -import { FiberState, type Context } from 'cordis' +import { FiberState, type Context } from '@deepseek-ai/cordis' export const name = 'diagnose' @@ -85,7 +85,7 @@ export function apply(ctx: Context) { And a plugin with an unsatisfiable dependency, `needs-timer.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'needs-timer' export const inject = ['timer'] @@ -106,7 +106,7 @@ Run it (plain `node --import tsx ../../vendor/cordis/bin.js`; stop with Ctrl-C): needs-timer is PENDING — a required service is missing ``` -`inject: ['timer']` has no provider. Add `- name: '@cordisjs/plugin-timer'` to the list and the plugin loads. When a plugin does nothing and reports nothing, inspect its fiber state. Iterating without the PENDING filter also shows the loader's own plugins (Loader, Include) as ACTIVE fibers because plugins mount the config file itself. +`inject: ['timer']` has no provider. Add `- name: '@deepseek-ai/cordis-plugin-timer'` to the list and the plugin loads. When a plugin does nothing and reports nothing, inspect its fiber state. Iterating without the PENDING filter also shows the loader's own plugins (Loader, Include) as ACTIVE fibers because plugins mount the config file itself. Next: [Into the harness](07-into-the-harness.md) — the same patterns against real harness services. diff --git a/docs/cordis-tutorial/06-composition-and-hmr.zh.md b/docs/cordis-tutorial/06-composition-and-hmr.zh.md index 07ae46555c..4aefb5ecd0 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.zh.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.zh.md @@ -22,24 +22,24 @@ Cordis 配置项除了 `name` 和 `config`,还接受其他元数据: ## 热模块替换 -卸载会释放 effect([第 2 章](02-lifecycle-and-effects.md)),加载则遵循依赖关系([第 3 章](03-services.md)),因此 HMR 可以先卸载、再加载,以替换正在运行的插件。`@cordisjs/plugin-hmr` 插件会监视文件,并在保存时执行这一过程。 +卸载会释放 effect([第 2 章](02-lifecycle-and-effects.md)),加载则遵循依赖关系([第 3 章](03-services.md)),因此 HMR 可以先卸载、再加载,以替换正在运行的插件。`@deepseek-ai/cordis-plugin-hmr` 插件会监视文件,并在保存时执行这一过程。 在 `tmp/cordis-tutorial` 中编写 `cordis.yml`: ```yaml - id: logger - name: '@cordisjs/plugin-logger-console' + name: '@deepseek-ai/cordis-plugin-logger-console' - id: timer - name: '@cordisjs/plugin-timer' + name: '@deepseek-ai/cordis-plugin-timer' - id: hmr - name: '@cordisjs/plugin-hmr' + name: '@deepseek-ai/cordis-plugin-hmr' config: root: ['.'] - id: hello name: './hello.ts' ``` -列表中增加了两个辅助插件:HMR 通过 Cordis logger 服务记录日志,因此没有控制台导出器时看不到其消息;它还会 `inject` `timer` 服务来实现去抖,如果没有 `@cordisjs/plugin-timer`,它就会永远停在 PENDING,而且不发出任何提示。下一节就讨论这种静默状态。 +列表中增加了两个辅助插件:HMR 通过 Cordis logger 服务记录日志,因此没有控制台导出器时看不到其消息;它还会 `inject` `timer` 服务来实现去抖,如果没有 `@deepseek-ai/cordis-plugin-timer`,它就会永远停在 PENDING,而且不发出任何提示。下一节就讨论这种静默状态。 HMR 通过 Loader 的原生辅助工具读取 Node 的 loader 内部结构。请在 tsx 下运行 Cordis: @@ -65,7 +65,7 @@ hello from my EDITED plugin 你可以直接查看这些状态。每个上下文都能枚举插件注册表;创建 `diagnose.ts`: ```ts -import { FiberState, type Context } from 'cordis' +import { FiberState, type Context } from '@deepseek-ai/cordis' export const name = 'diagnose' @@ -85,7 +85,7 @@ export function apply(ctx: Context) { 再创建一个依赖无法满足的插件 `needs-timer.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'needs-timer' export const inject = ['timer'] @@ -106,7 +106,7 @@ export function apply(ctx: Context) { needs-timer is PENDING — a required service is missing ``` -`inject: ['timer']` 没有提供方。向列表添加 `- name: '@cordisjs/plugin-timer'` 后,插件就会加载。如果插件既不执行任何操作,也不报告任何内容,请检查其 fiber 状态。不加 PENDING 过滤条件进行迭代时,还会看到 loader 自身的插件(Loader、Include)处于 ACTIVE,因为配置文件本身也是通过插件挂载的。 +`inject: ['timer']` 没有提供方。向列表添加 `- name: '@deepseek-ai/cordis-plugin-timer'` 后,插件就会加载。如果插件既不执行任何操作,也不报告任何内容,请检查其 fiber 状态。不加 PENDING 过滤条件进行迭代时,还会看到 loader 自身的插件(Loader、Include)处于 ACTIVE,因为配置文件本身也是通过插件挂载的。 下一章:[进入 harness](07-into-the-harness.md):把相同模式用于真实的 harness 服务。 diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml index 8fb1893fca..cc42357525 100644 --- a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml +++ b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/07-into-the-harness.md -07-into-the-harness.md: 69133786f58541b015aed080f4ac8fb2a7e488c0 -07-into-the-harness.zh.md: bc9c61da984e3eb691eb6bfbe59ae556823e82de +07-into-the-harness.md: 41a30f032ac02d8e9e3b17ac8d9cd13e9973e36a +07-into-the-harness.zh.md: 159cede00d453796f6a2cacae184229d71e17f87 diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index 69133786f5..41a30f032a 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -9,7 +9,7 @@ This chapter registers a model-callable tool with the harness's `tools` service, Create `greet-tool.ts` in `tmp/cordis-tutorial`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import { CallId } from '@deepseek-ai/dsh-llm' @@ -53,7 +53,7 @@ Every pattern here is from the earlier chapters: `inject: ['tools']` ([chapter 3 Create `tool-logger.ts` — a separate plugin that watches every tool call in the app through the harness's `tools/result` event: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-tools' export const name = 'tool-logger' diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md index bc9c61da98..159cede00d 100644 --- a/docs/cordis-tutorial/07-into-the-harness.zh.md +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -9,7 +9,7 @@ 创建 `greet-tool.ts`,将它放在 `tmp/cordis-tutorial` 中: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import { CallId } from '@deepseek-ai/dsh-llm' @@ -53,7 +53,7 @@ export function apply(ctx: Context) { 创建 `tool-logger.ts`。这是一个独立插件,通过 harness 的 `tools/result` 事件观察应用中的每次工具调用: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-tools' export const name = 'tool-logger' diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml index 719e949ffe..234d911a10 100644 --- a/docs/cordis-tutorial/index.i18n.yaml +++ b/docs/cordis-tutorial/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/index.md -index.md: 307c12854b3075cfd4dd5ea8a19806c58b4e998d -index.zh.md: a0107b7d15272e6ef8d526b9c0e03a99275644d6 +index.md: cf61fec07acd2022591cd858ba4146b6a4ae1a3d +index.zh.md: 775bf2fce2138b9edd91f5ee46b93b8c4eb4559e diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md index 307c12854b..cf61fec07a 100644 --- a/docs/cordis-tutorial/index.md +++ b/docs/cordis-tutorial/index.md @@ -50,8 +50,8 @@ That one-file launcher (see [vendor/cordis/bin.js](../../vendor/cordis/bin.js)) The examples use three TypeScript features beyond ordinary modern JavaScript: - **Type annotations** describe values without changing runtime behavior: `ctx: Context` says that `ctx` has the Cordis context API, `who: string` accepts text, and `string[]` means an array of strings. -- **`import type { Context } from 'cordis'`** imports only type information. It vanishes at runtime, so a plugin file that needs `Context` solely for annotations adds no runtime dependency. -- **Declaration merging** (`declare module 'cordis' { ... }`) adds your entries to interfaces that Cordis already declares — for example the type of a new `ctx.greeter` property or event name. It generates no runtime wiring; the plugin separately provides the service or emits the event. Chapter 3 shows the pattern in full. +- **`import type { Context } from '@deepseek-ai/cordis'`** imports only type information. It vanishes at runtime, so a plugin file that needs `Context` solely for annotations adds no runtime dependency. +- **Declaration merging** (`declare module '@deepseek-ai/cordis' { ... }`) adds your entries to interfaces that Cordis already declares — for example the type of a new `ctx.greeter` property or event name. It generates no runtime wiring; the plugin separately provides the service or emits the event. Chapter 3 shows the pattern in full. Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema<Config>` to say which object fields a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md index a0107b7d15..775bf2fce2 100644 --- a/docs/cordis-tutorial/index.zh.md +++ b/docs/cordis-tutorial/index.zh.md @@ -50,8 +50,8 @@ node --import tsx ../../vendor/cordis/bin.js 这些示例使用了普通现代 JavaScript 之外的三项 TypeScript 功能: - **类型注解** 描述值,但不会改变运行时行为:`ctx: Context` 表示 `ctx` 具备 Cordis 上下文 API,`who: string` 接受文本,而 `string[]` 表示字符串数组。 -- **`import type { Context } from 'cordis'`** 只导入类型信息。它在运行时会消失,因此仅为类型注解使用 `Context` 的插件文件不会增加运行时依赖。 -- **声明合并**(`declare module 'cordis' { ... }`)会为 Cordis 已经声明的接口添加你的条目,例如新 `ctx.greeter` 属性的类型或事件名称。它不会生成任何运行时接线;插件必须另行提供服务或发出事件。第 3 章会完整展示该模式。 +- **`import type { Context } from '@deepseek-ai/cordis'`** 只导入类型信息。它在运行时会消失,因此仅为类型注解使用 `Context` 的插件文件不会增加运行时依赖。 +- **声明合并**(`declare module '@deepseek-ai/cordis' { ... }`)会为 Cordis 已经声明的接口添加你的条目,例如新 `ctx.greeter` 属性的类型或事件名称。它不会生成任何运行时接线;插件必须另行提供服务或发出事件。第 3 章会完整展示该模式。 第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema<Config>` 这类泛型表示 schema 校验哪些对象字段。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 diff --git a/docs/user/develop/basic/config.i18n.yaml b/docs/user/develop/basic/config.i18n.yaml index 2887d77209..b367fbb82e 100644 --- a/docs/user/develop/basic/config.i18n.yaml +++ b/docs/user/develop/basic/config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/config.md -config.md: 02998c32415b5ba7acf82700034cabc1f7314f33 -config.zh.md: 161af5d6703b4cc77d63443a5a80846593d1c7fd +config.md: 21ba39fd7de1795e9139aff3e2b11743eedd4833 +config.zh.md: a882c4d59b0ac8e8ec27a5b32da5376b534a7f62 diff --git a/docs/user/develop/basic/config.md b/docs/user/develop/basic/config.md index 02998c3241..21ba39fd7d 100644 --- a/docs/user/develop/basic/config.md +++ b/docs/user/develop/basic/config.md @@ -9,8 +9,8 @@ Accept configuration supplied through `cordis.yml`. Export a `Config` type and a same-named Schemastery schema. Put defaults directly on the schema fields: ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' export const name = 'my-plugin' @@ -49,8 +49,8 @@ When loading the plugin, Cordis uses the exported schema to validate configurati Use Schemastery to express stricter validation: ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' export const name = 'validated-plugin' diff --git a/docs/user/develop/basic/config.zh.md b/docs/user/develop/basic/config.zh.md index 161af5d670..a882c4d59b 100644 --- a/docs/user/develop/basic/config.zh.md +++ b/docs/user/develop/basic/config.zh.md @@ -9,8 +9,8 @@ 在插件中导出一个 `Config` 类型和同名的 Schemastery schema;默认值直接写在 schema 中: ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' export const name = 'my-plugin' @@ -49,8 +49,8 @@ export function apply(ctx: Context, config: Config) { 对于需要严格校验的场景,使用 Schemastery 定义 schema: ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' export const name = 'validated-plugin' diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml index 0d89e9622e..bb4870f624 100644 --- a/docs/user/develop/basic/index.i18n.yaml +++ b/docs/user/develop/basic/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/index.md -index.md: 7fe66bb19ddb978a4b5a96768b62151b97bca0ec -index.zh.md: 59f4e5b58b6cf1fbc15de8fafb5f4b0db2e220d6 +index.md: fe525018011809abe4dd18ac5739c54cd330c8f6 +index.zh.md: 0dbb7275be3c7147395041fb18d49a7f6ea69cdd diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md index 7fe66bb19d..fe52501801 100644 --- a/docs/user/develop/basic/index.md +++ b/docs/user/develop/basic/index.md @@ -17,7 +17,7 @@ mkdir -p scratch-plugin/src In Harness, a plugin is a TypeScript module that exports an `apply` function. The framework calls `apply` when loading the plugin and passes a `ctx` context object through which the plugin registers capabilities: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'my-plugin' @@ -33,7 +33,7 @@ That is the complete configuration. Create `scratch-plugin/src/my-plugin.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'hello-plugin' @@ -68,7 +68,7 @@ Anything registered through `ctx`—event listeners, tools, or timers—is clean For a resource that needs explicit cleanup, such as a network connection, use `ctx.effect()` to provide its disposer: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export function apply(ctx: Context) { ctx.effect(() => { @@ -87,7 +87,7 @@ export function apply(ctx: Context) { If the plugin consumes another service such as `tools` or `llm`, declare it in `inject`: ```ts ignore-check -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'my-tool-plugin' export const inject = ['tools'] @@ -107,7 +107,7 @@ In addition to a function module, a plugin can use object or class form. ### Object form ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export default { name: 'my-plugin', @@ -121,7 +121,7 @@ export default { ### Class form ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' export default class MyService extends Service { static inject = ['tools'] diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md index 59f4e5b58b..0dbb7275be 100644 --- a/docs/user/develop/basic/index.zh.md +++ b/docs/user/develop/basic/index.zh.md @@ -17,7 +17,7 @@ mkdir -p scratch-plugin/src 在 Harness 中,插件是一个导出 `apply` 函数的 TypeScript 模块。框架在加载时调用 `apply`,传入一个 `ctx`(上下文对象),你通过 `ctx` 注册能力: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'my-plugin' @@ -33,7 +33,7 @@ export function apply(ctx: Context) { 创建 `scratch-plugin/src/my-plugin.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'hello-plugin' @@ -68,7 +68,7 @@ pnpm run dsh web --patch ./scratch-plugin/cordis.yml 如果你有需要手动清理的资源(比如一个网络连接),用 `ctx.effect()` 告诉框架怎么清理: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export function apply(ctx: Context) { ctx.effect(() => { @@ -87,7 +87,7 @@ export function apply(ctx: Context) { 如果你的插件需要使用其他服务(如 `tools`、`llm`),需要声明 `inject`: ```ts ignore-check -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'my-tool-plugin' export const inject = ['tools'] @@ -107,7 +107,7 @@ export function apply(ctx: Context) { ### 对象形式 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export default { name: 'my-plugin', @@ -121,7 +121,7 @@ export default { ### 类形式 ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' export default class MyService extends Service { static inject = ['tools'] diff --git a/docs/user/develop/basic/tool.i18n.yaml b/docs/user/develop/basic/tool.i18n.yaml index 594e2c2872..3ddc760f7d 100644 --- a/docs/user/develop/basic/tool.i18n.yaml +++ b/docs/user/develop/basic/tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/tool.md -tool.md: ba2f3b1302ba31735d67be264f498a0395394d06 -tool.zh.md: 676f8fc996d752a05d94b55d4522e61e3b3e2161 +tool.md: f110bd2c10caf21ea8c87bc32fd43b01b209a9d1 +tool.zh.md: a237b6015de0ea6141a94c56aaa6f68c73fa3feb diff --git a/docs/user/develop/basic/tool.md b/docs/user/develop/basic/tool.md index ba2f3b1302..f110bd2c10 100644 --- a/docs/user/develop/basic/tool.md +++ b/docs/user/develop/basic/tool.md @@ -9,7 +9,7 @@ This tutorial adds a `greet` tool to the Web UI. Complete [Your first plugin](./ Replace `scratch-plugin/src/my-plugin.ts` with: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'greet-tool' diff --git a/docs/user/develop/basic/tool.zh.md b/docs/user/develop/basic/tool.zh.md index 676f8fc996..a237b6015d 100644 --- a/docs/user/develop/basic/tool.zh.md +++ b/docs/user/develop/basic/tool.zh.md @@ -9,7 +9,7 @@ 将 `scratch-plugin/src/my-plugin.ts` 替换为: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'greet-tool' diff --git a/docs/user/develop/framework/events.i18n.yaml b/docs/user/develop/framework/events.i18n.yaml index f39b04145d..ebc4dc7833 100644 --- a/docs/user/develop/framework/events.i18n.yaml +++ b/docs/user/develop/framework/events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/framework/events.md -events.md: 8a8c076d9c7b40d73182db074c4f494fded8c6dd -events.zh.md: 9649d89d575a1b05fa524bd460043da71dc9ae43 +events.md: 4b5f9ee215186398ee5aee7a438f792f9b5a3639 +events.zh.md: b48c8020803239d3c9636f81052d3b70afa315f2 diff --git a/docs/user/develop/framework/events.md b/docs/user/develop/framework/events.md index 8a8c076d9c..4b5f9ee215 100644 --- a/docs/user/develop/framework/events.md +++ b/docs/user/develop/framework/events.md @@ -85,9 +85,9 @@ A waterfall listener **must call `next()`**. Omitting it short-circuits the pipe Harness uses TypeScript declaration merging for type-safe events: ```ts -import 'cordis' +import '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { 'my-plugin/ready': (payload: { id: string }) => void 'my-plugin/check': (input: string) => boolean | undefined @@ -121,7 +121,7 @@ export function apply(ctx: Context) { This plugin logs tool calls and results: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import '@deepseek-ai/dsh-tools' export const name = 'tool-logger' diff --git a/docs/user/develop/framework/events.zh.md b/docs/user/develop/framework/events.zh.md index 9649d89d57..b48c802080 100644 --- a/docs/user/develop/framework/events.zh.md +++ b/docs/user/develop/framework/events.zh.md @@ -85,9 +85,9 @@ waterfall 监听器**必须调用 `next()`**。不调用 `next` 会短路整个 Harness 使用 TypeScript 声明合并来为事件提供类型安全: ```ts -import 'cordis' +import '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { 'my-plugin/ready': (payload: { id: string }) => void 'my-plugin/check': (input: string) => boolean | undefined @@ -121,7 +121,7 @@ export function apply(ctx: Context) { 这个插件记录工具调用和工具结果: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import '@deepseek-ai/dsh-tools' export const name = 'tool-logger' diff --git a/docs/user/develop/framework/index.i18n.yaml b/docs/user/develop/framework/index.i18n.yaml index d06be13bdd..1c8dc3dae4 100644 --- a/docs/user/develop/framework/index.i18n.yaml +++ b/docs/user/develop/framework/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/framework/index.md -index.md: 79e925b54509da41535735527e283850384257ec -index.zh.md: 962677dc468c9cc233a51d50758247e028d9c3ed +index.md: 85701ce281d92da0c805b39291179df73eb65f51 +index.zh.md: 871aa55ef81a7dcbfe3cbde5986244220ee32f98 diff --git a/docs/user/develop/framework/index.md b/docs/user/develop/framework/index.md index 79e925b545..85701ce281 100644 --- a/docs/user/develop/framework/index.md +++ b/docs/user/develop/framework/index.md @@ -80,7 +80,7 @@ export function apply(ctx: Context) { To stop a plugin instance early: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' declare const ctx: Context declare function myPlugin(ctx: Context): void @@ -98,7 +98,7 @@ await fiber.dispose() ## Hot replacement (HMR) -With `@cordisjs/plugin-hmr` loaded from `cordis.yml`, editing a plugin source file triggers: +With `@deepseek-ai/cordis-plugin-hmr` loaded from `cordis.yml`, editing a plugin source file triggers: 1. Unload the old plugin and clean up its registrations. 2. Load the new code. diff --git a/docs/user/develop/framework/index.zh.md b/docs/user/develop/framework/index.zh.md index 962677dc46..871aa55ef8 100644 --- a/docs/user/develop/framework/index.zh.md +++ b/docs/user/develop/framework/index.zh.md @@ -80,7 +80,7 @@ export function apply(ctx: Context) { 当你需要提前终止一个插件实例: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' declare const ctx: Context declare function myPlugin(ctx: Context): void @@ -98,7 +98,7 @@ await fiber.dispose() ## HMR(热模块替换) -通过 `cordis.yml` 加载 `@cordisjs/plugin-hmr` 后,修改插件源文件会触发: +通过 `cordis.yml` 加载 `@deepseek-ai/cordis-plugin-hmr` 后,修改插件源文件会触发: 1. 卸载旧插件(清理所有注册) 2. 重新加载新代码 diff --git a/docs/user/develop/framework/service.i18n.yaml b/docs/user/develop/framework/service.i18n.yaml index 7cb2f4ff88..29151de351 100644 --- a/docs/user/develop/framework/service.i18n.yaml +++ b/docs/user/develop/framework/service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/framework/service.md -service.md: 040b1388cc431c30045e05f7d372ab5885bb3f9d -service.zh.md: 0786b684c1688440a24cc729288835ad636f8ff7 +service.md: 3358f82ca5391741a7f531b7504de7335959ad03 +service.zh.md: 8fb4beeac43051c0f08483fe61e22c588127c360 diff --git a/docs/user/develop/framework/service.md b/docs/user/develop/framework/service.md index 040b1388cc..3358f82ca5 100644 --- a/docs/user/develop/framework/service.md +++ b/docs/user/develop/framework/service.md @@ -36,7 +36,7 @@ When `apply` runs, every service declared by `inject` is ready. If a service is ### Extend Service ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' export default class MetricsService extends Service { static inject = ['llm'] // A service may depend on other services. @@ -67,9 +67,9 @@ export function apply(ctx: Context) { Use TypeScript declaration merging to type `ctx.metrics`: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { metrics: MetricsService } @@ -114,7 +114,7 @@ This prevents a plugin from calling a service that no longer exists. ```yaml - id: group-a - name: '@cordisjs/plugin-group' + name: '@deepseek-ai/cordis-plugin-group' group: true isolate: bash: true @@ -125,7 +125,7 @@ This prevents a plugin from calling a service that no longer exists. - name: './src/plugin-a.ts' - id: group-b - name: '@cordisjs/plugin-group' + name: '@deepseek-ai/cordis-plugin-group' group: true isolate: bash: true diff --git a/docs/user/develop/framework/service.zh.md b/docs/user/develop/framework/service.zh.md index 0786b684c1..8fb4beeac4 100644 --- a/docs/user/develop/framework/service.zh.md +++ b/docs/user/develop/framework/service.zh.md @@ -36,7 +36,7 @@ export function apply(ctx: Context) { ### 使用 Service 基类 ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' export default class MetricsService extends Service { static inject = ['llm'] // A service may depend on other services. @@ -67,9 +67,9 @@ export function apply(ctx: Context) { 使用 TypeScript 声明合并让 `ctx.metrics` 有正确类型: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { metrics: MetricsService } @@ -114,7 +114,7 @@ export function apply(ctx: Context) { ```yaml - id: group-a - name: '@cordisjs/plugin-group' + name: '@deepseek-ai/cordis-plugin-group' group: true isolate: bash: true @@ -125,7 +125,7 @@ export function apply(ctx: Context) { - name: './src/plugin-a.ts' - id: group-b - name: '@cordisjs/plugin-group' + name: '@deepseek-ai/cordis-plugin-group' group: true isolate: bash: true diff --git a/docs/user/develop/practice/index.i18n.yaml b/docs/user/develop/practice/index.i18n.yaml index fc15dfeb2f..1bb86a5cbb 100644 --- a/docs/user/develop/practice/index.i18n.yaml +++ b/docs/user/develop/practice/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/practice/index.md -index.md: 1eb33e17ab6c5d0a2b37ff97d5948dfbcba497ca -index.zh.md: 31afa80407f81f571615b5ed68a9370775f5188f +index.md: 7ca9f0b1abe472dc90c6d4e56543e43b6d2ec727 +index.zh.md: 216b1cb01b355e411bf1949c59fd3720ae47139e diff --git a/docs/user/develop/practice/index.md b/docs/user/develop/practice/index.md index 1eb33e17ab..7ca9f0b1ab 100644 --- a/docs/user/develop/practice/index.md +++ b/docs/user/develop/practice/index.md @@ -61,9 +61,9 @@ The [capability-seam reference](../../../capability-seams.md) owns the current b ```ts ignore-check // packages/my-cap/my-cap/src/index.ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { myCap: MyCapService } @@ -91,7 +91,7 @@ export interface MyCapResult { ```ts ignore-check // packages/my-cap/my-cap-local/src/index.ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/dsh-my-cap' class MyCapLocal extends MyCapService { @@ -112,7 +112,7 @@ export function apply(ctx: Context) { ```ts ignore-check // packages/my-cap/tool-my-cap/src/index.ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'tool-my-cap' diff --git a/docs/user/develop/practice/index.zh.md b/docs/user/develop/practice/index.zh.md index 31afa80407..216b1cb01b 100644 --- a/docs/user/develop/practice/index.zh.md +++ b/docs/user/develop/practice/index.zh.md @@ -61,9 +61,9 @@ ```ts ignore-check // packages/my-cap/my-cap/src/index.ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { myCap: MyCapService } @@ -91,7 +91,7 @@ export interface MyCapResult { ```ts ignore-check // packages/my-cap/my-cap-local/src/index.ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/dsh-my-cap' class MyCapLocal extends MyCapService { @@ -112,7 +112,7 @@ export function apply(ctx: Context) { ```ts ignore-check // packages/my-cap/tool-my-cap/src/index.ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'tool-my-cap' diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index a7a745faaf..c8487899ec 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/practice/llm-adapter.md -llm-adapter.md: 7445688530c1ba61e5c065f9f5e49db6498da5b1 -llm-adapter.zh.md: c726735ff2679584d1c061ef8acddc8981dadd26 +llm-adapter.md: aba4a6d0c8ee42e78ca5a804d9a0dd9b31c1e240 +llm-adapter.zh.md: dff9eef464599823d6cd99e83d668485109b2ec0 diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 7445688530..aba4a6d0c8 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -11,8 +11,8 @@ An LLM adapter extends `LlmAdapter` and implements `stream()`, translating Harne ## Minimal implementation ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' class MyAdapter extends LlmAdapter { diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index c726735ff2..dff9eef464 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -11,8 +11,8 @@ LLM 适配器是一个继承 `LlmAdapter` 并实现 `stream()` 方法的类, ## 最小实现 ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' class MyAdapter extends LlmAdapter { diff --git a/examples/acp-agent/advanced.cordis.snapshot.yml b/examples/acp-agent/advanced.cordis.snapshot.yml index c89fdaf2a6..19d4a79fca 100644 --- a/examples/acp-agent/advanced.cordis.snapshot.yml +++ b/examples/acp-agent/advanced.cordis.snapshot.yml @@ -1,6 +1,6 @@ # Replay counterpart to advanced.cordis.yml; only the live model is replaced. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/advanced.cordis.yml b/examples/acp-agent/advanced.cordis.yml index aa20e1558d..1273d4f059 100644 --- a/examples/acp-agent/advanced.cordis.yml +++ b/examples/acp-agent/advanced.cordis.yml @@ -1,7 +1,7 @@ # Add Code Mode and Cordis tools to the base spawn/workflow stack, exercising # all four boundaries in one ACP snapshot. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/both-mode.cordis.snapshot.yml b/examples/acp-agent/both-mode.cordis.snapshot.yml index 84a286649a..8f2c92d62e 100644 --- a/examples/acp-agent/both-mode.cordis.snapshot.yml +++ b/examples/acp-agent/both-mode.cordis.snapshot.yml @@ -2,7 +2,7 @@ # swap. Include patches cannot target entries behind a nested include, so this file # applies both overlays directly to `cordis.yml`. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/both-mode.cordis.yml b/examples/acp-agent/both-mode.cordis.yml index d793e616f9..58e465176c 100644 --- a/examples/acp-agent/both-mode.cordis.yml +++ b/examples/acp-agent/both-mode.cordis.yml @@ -3,7 +3,7 @@ # this overlay for snapshot recording and the sibling overlay for replay. A config # patch replaces the whole app config, so unchanged base fields are restated below. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/child-question.cordis.snapshot.yml b/examples/acp-agent/child-question.cordis.snapshot.yml index 4eb0c5bfb4..d9d87e8db9 100644 --- a/examples/acp-agent/child-question.cordis.snapshot.yml +++ b/examples/acp-agent/child-question.cordis.snapshot.yml @@ -2,7 +2,7 @@ # seam, model-facing tool, and tripwire provider while replacing DeepSeek with # per-session replay. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/child-question.cordis.yml b/examples/acp-agent/child-question.cordis.yml index 803d21c35d..8de7fe135b 100644 --- a/examples/acp-agent/child-question.cordis.yml +++ b/examples/acp-agent/child-question.cordis.yml @@ -1,7 +1,7 @@ # Snapshot-only human-interaction composition. The provider is a tripwire: the # runtime-owned child must be rejected by the seam before any UI wait begins. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml b/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml index 684ac2b27d..650d950e69 100644 --- a/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless replay counterpart of code-mode-workspace-context.cordis.yml. It adds # Code Mode to the default filesystem suite and swaps in replay. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/code-mode-workspace-context.cordis.yml b/examples/acp-agent/code-mode-workspace-context.cordis.yml index a724a86961..02ca7d40de 100644 --- a/examples/acp-agent/code-mode-workspace-context.cordis.yml +++ b/examples/acp-agent/code-mode-workspace-context.cordis.yml @@ -1,7 +1,7 @@ # Code Mode workspace-context snapshot recording overlay. The default filesystem # tools trigger nested instruction discovery after a read. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/code-mode.cordis.snapshot.yml b/examples/acp-agent/code-mode.cordis.snapshot.yml index 992a442343..7c114d81f3 100644 --- a/examples/acp-agent/code-mode.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode.cordis.snapshot.yml @@ -2,7 +2,7 @@ # swap. Include patches cannot target entries behind a nested include, so this file # applies both overlays directly to `cordis.yml`. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/code-mode.cordis.yml b/examples/acp-agent/code-mode.cordis.yml index ec31a1fc87..8ad70610b9 100644 --- a/examples/acp-agent/code-mode.cordis.yml +++ b/examples/acp-agent/code-mode.cordis.yml @@ -4,7 +4,7 @@ # replay overlay for `DSH_SNAPSHOT=replay`. A config patch replaces the whole app # config, so unchanged base fields are restated below. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/cordis-tools.cordis.yml b/examples/acp-agent/cordis-tools.cordis.yml index c6e3e84457..87ba11858e 100644 --- a/examples/acp-agent/cordis-tools.cordis.yml +++ b/examples/acp-agent/cordis-tools.cordis.yml @@ -1,7 +1,7 @@ # Add the self-referential Cordis tools without changing the base ACP tool # presentation mode. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index 5bc771a0fc..46eff1e869 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -9,7 +9,7 @@ # `DSH_SNAPSHOT_OVERRIDE` from the harness. The one-shot patch applies at include # load time, and stdout remains reserved for ACP JSON-RPC. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/depth-two.cordis.snapshot.yml b/examples/acp-agent/depth-two.cordis.snapshot.yml index 3e292699d1..e988f9be60 100644 --- a/examples/acp-agent/depth-two.cordis.snapshot.yml +++ b/examples/acp-agent/depth-two.cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless counterpart to depth-two.cordis.yml: apply the depth patch and replace # the live adapter with per-session replay. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/depth-two.cordis.yml b/examples/acp-agent/depth-two.cordis.yml index 1af96e9283..73e02d7ffc 100644 --- a/examples/acp-agent/depth-two.cordis.yml +++ b/examples/acp-agent/depth-two.cordis.yml @@ -1,7 +1,7 @@ # Depth-limit snapshot overlay: keep the default composition and allow two # generations of spawn children before runtime enforcement rejects another. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/fs.cordis.snapshot.yml b/examples/acp-agent/fs.cordis.snapshot.yml index 0cab77bb36..ee922dba07 100644 --- a/examples/acp-agent/fs.cordis.snapshot.yml +++ b/examples/acp-agent/fs.cordis.snapshot.yml @@ -5,7 +5,7 @@ # `deepseek-v4-pro`, but the recorded corpus was captured on flash, and a config # patch replaces the whole app config, so the base fields are restated verbatim. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/fs.cordis.yml b/examples/acp-agent/fs.cordis.yml index 0d667255c8..c68e361302 100644 --- a/examples/acp-agent/fs.cordis.yml +++ b/examples/acp-agent/fs.cordis.yml @@ -2,7 +2,7 @@ # the base cordis.yml, so this overlay adds only the local tool-result spill # storage those scenarios exercise. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/partial-landlock.cordis.snapshot.yml b/examples/acp-agent/partial-landlock.cordis.snapshot.yml index af834b885d..ce48d20ac7 100644 --- a/examples/acp-agent/partial-landlock.cordis.snapshot.yml +++ b/examples/acp-agent/partial-landlock.cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless runner-classification composition: replay authored model turns and # replace the shipping provider with a deterministic process-launch stand-in. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/partial-landlock.cordis.yml b/examples/acp-agent/partial-landlock.cordis.yml index 7a958bb6de..2272c657d1 100644 --- a/examples/acp-agent/partial-landlock.cordis.yml +++ b/examples/acp-agent/partial-landlock.cordis.yml @@ -1,7 +1,7 @@ # Live counterpart for the runner-classification snapshot overlay. It replaces # only the sandbox provider; authored scenarios are skipped in record mode. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/pty.cordis.snapshot.yml b/examples/acp-agent/pty.cordis.snapshot.yml index c918f74c56..75e9f313b6 100644 --- a/examples/acp-agent/pty.cordis.snapshot.yml +++ b/examples/acp-agent/pty.cordis.snapshot.yml @@ -1,6 +1,6 @@ # Keyless replay counterpart to pty.cordis.yml. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/pty.cordis.yml b/examples/acp-agent/pty.cordis.yml index 5e911b29d0..0ff9c1d4b5 100644 --- a/examples/acp-agent/pty.cordis.yml +++ b/examples/acp-agent/pty.cordis.yml @@ -1,7 +1,7 @@ # Opt-in persistent PTY composition for the PTY snapshot scenario. The base # deployment already owns the shared sandbox provider and policy. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/retry.cordis.snapshot.yml b/examples/acp-agent/retry.cordis.snapshot.yml index 72370f1a70..c69b08fe64 100644 --- a/examples/acp-agent/retry.cordis.snapshot.yml +++ b/examples/acp-agent/retry.cordis.snapshot.yml @@ -3,7 +3,7 @@ # 1 ms zero-jitter retry policy as the live sibling. The app patch still # restates its whole config for raw JSONL persistence and the recorded model. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/retry.cordis.yml b/examples/acp-agent/retry.cordis.yml index 087faa271d..2da66a3e44 100644 --- a/examples/acp-agent/retry.cordis.yml +++ b/examples/acp-agent/retry.cordis.yml @@ -6,7 +6,7 @@ # adapter fields around `retryPolicy`, while the app patch re-pins the recorded # flash model and restates its base fields. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/session-query.cordis.snapshot.yml b/examples/acp-agent/session-query.cordis.snapshot.yml index 1edadf8374..33029a9c7a 100644 --- a/examples/acp-agent/session-query.cordis.snapshot.yml +++ b/examples/acp-agent/session-query.cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless counterpart to session-query.cordis.yml: the nested snapshot overlay # supplies replay plus deterministic private spill storage and its byte limit. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./fs.cordis.snapshot.yml patches: diff --git a/examples/acp-agent/session-query.cordis.yml b/examples/acp-agent/session-query.cordis.yml index e5e45025df..03ed085fa2 100644 --- a/examples/acp-agent/session-query.cordis.yml +++ b/examples/acp-agent/session-query.cordis.yml @@ -1,7 +1,7 @@ # Explicit session-query tool opt-in for the dedicated spill scenario. The # nested filesystem overlay supplies private spill storage and its byte limit. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./fs.cordis.yml patches: diff --git a/examples/acp-agent/session-sandbox-root.cordis.snapshot.yml b/examples/acp-agent/session-sandbox-root.cordis.snapshot.yml index 02b1d303d4..55627c17fa 100644 --- a/examples/acp-agent/session-sandbox-root.cordis.snapshot.yml +++ b/examples/acp-agent/session-sandbox-root.cordis.snapshot.yml @@ -3,7 +3,7 @@ # and the deliberately distinct sandbox fallback are applied together to the # live tree. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/session-sandbox-root.cordis.yml b/examples/acp-agent/session-sandbox-root.cordis.yml index f27732fd68..fd2d712882 100644 --- a/examples/acp-agent/session-sandbox-root.cordis.yml +++ b/examples/acp-agent/session-sandbox-root.cordis.yml @@ -3,7 +3,7 @@ # /tmp. A workspace-write mutation can therefore succeed only when the calling # session's cwd replaces the process-level fallback root. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/session-title.cordis.snapshot.yml b/examples/acp-agent/session-title.cordis.snapshot.yml index b10e82cfcc..4debc76a02 100644 --- a/examples/acp-agent/session-title.cordis.snapshot.yml +++ b/examples/acp-agent/session-title.cordis.snapshot.yml @@ -2,7 +2,7 @@ # the auxiliary route consumes replay.override.json with pacing so its accepted # title commits only after the main turn has closed. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/session-title.cordis.yml b/examples/acp-agent/session-title.cordis.yml index 0819b79d98..e18a6eeafd 100644 --- a/examples/acp-agent/session-title.cordis.yml +++ b/examples/acp-agent/session-title.cordis.yml @@ -2,7 +2,7 @@ # the ordinary DeepSeek route while the ACP app and every other capability stay # identical to the base example. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml b/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml index 7ce0733e53..2fc7c8a369 100644 --- a/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml +++ b/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless counterpart to subagent-durability-failure.cordis.yml: replace the # live adapter with replay and fail the provider-owned final child checkpoint. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/subagent-durability-failure.cordis.yml b/examples/acp-agent/subagent-durability-failure.cordis.yml index c033c323dc..ff5603093e 100644 --- a/examples/acp-agent/subagent-durability-failure.cordis.yml +++ b/examples/acp-agent/subagent-durability-failure.cordis.yml @@ -1,7 +1,7 @@ # Snapshot-only durability-failure overlay. The child turn's ordinary flush # succeeds; the provider-owned final confirmation fails deterministically. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/tests/fixtures/child-question-tripwire.ts b/examples/acp-agent/tests/fixtures/child-question-tripwire.ts index 7eb15ac551..7495e68819 100644 --- a/examples/acp-agent/tests/fixtures/child-question-tripwire.ts +++ b/examples/acp-agent/tests/fixtures/child-question-tripwire.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import '@deepseek-ai/dsh-user-interaction' /** Snapshot-only provider whose invocation means the child guard failed. */ diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index d3ffa4e8a7..8f5185026e 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { SessionId } from '@deepseek-ai/dsh-session' export const name = 'subagent-durability-failure' diff --git a/examples/acp-agent/tests/fixtures/subagent-settlement-marker.ts b/examples/acp-agent/tests/fixtures/subagent-settlement-marker.ts index e81bde95c4..8e4ad0a0e9 100644 --- a/examples/acp-agent/tests/fixtures/subagent-settlement-marker.ts +++ b/examples/acp-agent/tests/fixtures/subagent-settlement-marker.ts @@ -1,6 +1,6 @@ import { writeFileSync } from 'node:fs' import { join } from 'node:path' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-subagent' export const name = 'subagent-settlement-marker' diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts index 9d3857ffc8..f98c007125 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts index e2dc946dfe..781138d9ff 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts @@ -1,6 +1,6 @@ /** Parent adapter that fails if the composition-only Loader test starts a turn. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' diff --git a/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts b/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts index 6c1cd46fbf..e1819e430f 100644 --- a/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts +++ b/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml b/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml index a216a34cfd..4cc6ba1f3e 100644 --- a/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml +++ b/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml @@ -3,7 +3,7 @@ # entries behind a nested include; the acp-agent restatement keeps the recorded # deepseek-v4-flash model and raw JSONL persistence for the harness's harvest. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ../cordis.yml patches: diff --git a/examples/acp-agent/tests/fs-diff-bound.cordis.yml b/examples/acp-agent/tests/fs-diff-bound.cordis.yml index 9be25cdb25..c82a7ce63a 100644 --- a/examples/acp-agent/tests/fs-diff-bound.cordis.yml +++ b/examples/acp-agent/tests/fs-diff-bound.cordis.yml @@ -5,7 +5,7 @@ # verbatim, and the acp-agent restatement re-pins `deepseek-v4-flash` to match # the recorded corpus and its pinned request headers. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ../cordis.yml patches: diff --git a/examples/acp-agent/tests/lsp.cordis.snapshot.yml b/examples/acp-agent/tests/lsp.cordis.snapshot.yml index dc672376b5..574d8f646f 100644 --- a/examples/acp-agent/tests/lsp.cordis.snapshot.yml +++ b/examples/acp-agent/tests/lsp.cordis.snapshot.yml @@ -1,6 +1,6 @@ # Keyless replay keeps the LSP composition intact and replaces only the model adapter. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ../cordis.yml patches: diff --git a/examples/acp-agent/tests/lsp.cordis.yml b/examples/acp-agent/tests/lsp.cordis.yml index 49c9099d65..b9600d5d09 100644 --- a/examples/acp-agent/tests/lsp.cordis.yml +++ b/examples/acp-agent/tests/lsp.cordis.yml @@ -1,7 +1,7 @@ # Exercise the model-facing LSP tool through the shipped ACP app and Loader entry path. # The scenario workspace supplies the deterministic stdio server used by this test composition. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ../cordis.yml patches: diff --git a/examples/acp-agent/web.cordis.snapshot.yml b/examples/acp-agent/web.cordis.snapshot.yml index f0da7617b0..facd66f76c 100644 --- a/examples/acp-agent/web.cordis.snapshot.yml +++ b/examples/acp-agent/web.cordis.snapshot.yml @@ -2,7 +2,7 @@ # fixture server stay real (the tool call re-executes the actual HTTP fetch and # markdown rendering); only the model adapter is replaced by replay. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/web.cordis.yml b/examples/acp-agent/web.cordis.yml index 1ed0b3efba..63ce043340 100644 --- a/examples/acp-agent/web.cordis.yml +++ b/examples/acp-agent/web.cordis.yml @@ -4,7 +4,7 @@ # fixture server the scenario prompt fetches — deterministic content, no # external network, in recording and replay alike. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/workspace-context.cordis.snapshot.yml b/examples/acp-agent/workspace-context.cordis.snapshot.yml index 6abaaa3f13..0215128de1 100644 --- a/examples/acp-agent/workspace-context.cordis.snapshot.yml +++ b/examples/acp-agent/workspace-context.cordis.snapshot.yml @@ -2,7 +2,7 @@ # compose across includes, so this applies the scenario config and model swap # directly to the live tree. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/workspace-context.cordis.yml b/examples/acp-agent/workspace-context.cordis.yml index 34562e7324..db9fb85353 100644 --- a/examples/acp-agent/workspace-context.cordis.yml +++ b/examples/acp-agent/workspace-context.cordis.yml @@ -2,7 +2,7 @@ # discovery inside the scenario's temporary cwd. The app config patch replaces # the whole base config, so the base fields are restated verbatim. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/advanced.cordis.snapshot.yml b/examples/headless-agent/advanced.cordis.snapshot.yml index 881a976e33..6ce18953cc 100644 --- a/examples/headless-agent/advanced.cordis.snapshot.yml +++ b/examples/headless-agent/advanced.cordis.snapshot.yml @@ -8,7 +8,7 @@ # disables the key-requiring DeepSeek adapter and inserts `llm-replay` to serve # recorded JSONL without a key or network. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/advanced.cordis.yml b/examples/headless-agent/advanced.cordis.yml index 09ecec6ea2..ab0bd18d6d 100644 --- a/examples/headless-agent/advanced.cordis.yml +++ b/examples/headless-agent/advanced.cordis.yml @@ -1,6 +1,6 @@ # Add Code Mode and Cordis tools to the headless spawn/workflow stack. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/compaction.cordis.snapshot.yml b/examples/headless-agent/compaction.cordis.snapshot.yml index 13515cf5cc..56431c6e37 100644 --- a/examples/headless-agent/compaction.cordis.snapshot.yml +++ b/examples/headless-agent/compaction.cordis.snapshot.yml @@ -1,6 +1,6 @@ # Keyless context-overflow composition for the assembled compaction snapshot. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/credentials.cordis.snapshot.yml b/examples/headless-agent/credentials.cordis.snapshot.yml index 3a8638089a..7f1cb2b1f2 100644 --- a/examples/headless-agent/credentials.cordis.snapshot.yml +++ b/examples/headless-agent/credentials.cordis.snapshot.yml @@ -3,7 +3,7 @@ # the deepseek-official route still registers — so the prompt fails with the actionable # MISSING_CREDENTIAL guidance this snapshot pins as first-run UX. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/e2b.cordis.yml b/examples/headless-agent/e2b.cordis.yml index 61bdbc8aa7..3c48a397ad 100644 --- a/examples/headless-agent/e2b.cordis.yml +++ b/examples/headless-agent/e2b.cordis.yml @@ -8,7 +8,7 @@ # falls back to /home/user/workspace while Bash and PTY keep targeting the # host path, so every tool call fails with a remote spawn error. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./advanced.cordis.yml patches: diff --git a/examples/headless-agent/goal.cordis.snapshot.yml b/examples/headless-agent/goal.cordis.snapshot.yml index f6eeeb05ec..00c34e272e 100644 --- a/examples/headless-agent/goal.cordis.snapshot.yml +++ b/examples/headless-agent/goal.cordis.snapshot.yml @@ -2,7 +2,7 @@ # a config patch cannot target an entry behind a nested include, then restates # the goal overlay while replacing the live model with keyless replay. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/goal.cordis.yml b/examples/headless-agent/goal.cordis.yml index 8f8cdf9e0b..fe84de7bf5 100644 --- a/examples/headless-agent/goal.cordis.yml +++ b/examples/headless-agent/goal.cordis.yml @@ -1,6 +1,6 @@ # Add the persisted goal domain and its model-facing tools to the real one-shot app. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/pty.cordis.snapshot.yml b/examples/headless-agent/pty.cordis.snapshot.yml index 49f3a95afb..3fb20ff468 100644 --- a/examples/headless-agent/pty.cordis.snapshot.yml +++ b/examples/headless-agent/pty.cordis.snapshot.yml @@ -1,6 +1,6 @@ # Keyless opt-in PTY composition for the headless stream-json snapshot. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/ralph.cordis.snapshot.yml b/examples/headless-agent/ralph.cordis.snapshot.yml index e84bdfed31..87e5619cde 100644 --- a/examples/headless-agent/ralph.cordis.snapshot.yml +++ b/examples/headless-agent/ralph.cordis.snapshot.yml @@ -1,6 +1,6 @@ # Replay counterpart to cordis.yml for the shipped Ralph-loop snapshot. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/retry.cordis.snapshot.yml b/examples/headless-agent/retry.cordis.snapshot.yml index b8fc79d17f..30a67f45f6 100644 --- a/examples/headless-agent/retry.cordis.snapshot.yml +++ b/examples/headless-agent/retry.cordis.snapshot.yml @@ -1,6 +1,6 @@ # Keyless provider-retry composition for the headless stream-json snapshot. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index ad049ab308..d360f7083d 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId, HarnessError } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/examples/headless-agent/tests/coding-task.e2e.ts b/examples/headless-agent/tests/coding-task.e2e.ts index 75b10ee2bc..42b87eff8a 100644 --- a/examples/headless-agent/tests/coding-task.e2e.ts +++ b/examples/headless-agent/tests/coding-task.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/examples/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index 35a9a1627e..94dd510b84 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-agent/tests/compaction.e2e.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts index b0b120ecb2..57cb384138 100644 --- a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts +++ b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { CallId, LlmAdapter, diff --git a/examples/headless-agent/tests/fixtures/cli.cordis.yml b/examples/headless-agent/tests/fixtures/cli.cordis.yml index b9df4d29f7..3106467857 100644 --- a/examples/headless-agent/tests/fixtures/cli.cordis.yml +++ b/examples/headless-agent/tests/fixtures/cli.cordis.yml @@ -2,7 +2,7 @@ name: './cli-mock-llm.ts' - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ../../cordis.yml patches: diff --git a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml index 930363cf37..fab8ae1b7b 100644 --- a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml +++ b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml @@ -1,5 +1,5 @@ - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ../../cordis.yml patches: diff --git a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts index d8dc2c2465..9776438079 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts +++ b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts @@ -1,6 +1,6 @@ /** Test-only Loader plugin that creates a goal at the first real step edge. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-goal' export const name = 'seed-goal' diff --git a/examples/headless-agent/tests/fixtures/headless-driver.ts b/examples/headless-agent/tests/fixtures/headless-driver.ts index 88d7d73b67..8020129bfb 100644 --- a/examples/headless-agent/tests/fixtures/headless-driver.ts +++ b/examples/headless-agent/tests/fixtures/headless-driver.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node /** Snapshot-only Loader driver: stream one fixture turn as canonical JSONL. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { runFixtureTurn } from '@deepseek-ai/dsh-loader-smoke' import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs b/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs index 5a2fc5d128..76981fe79c 100644 --- a/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs +++ b/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs @@ -46,7 +46,7 @@ export const inject = ['llm'] /** * Register the deterministic provider adapter. - * @param {import('cordis').Context} ctx - plugin context carrying the LLM service. + * @param {import('@deepseek-ai/cordis').Context} ctx - plugin context carrying the LLM service. */ export function apply(ctx) { ctx.llm.registerAdapter(['deepseek-official'], new RetrySnapshotAdapter()) diff --git a/examples/headless-agent/tests/fixtures/semantic-checkpoint-agent.ts b/examples/headless-agent/tests/fixtures/semantic-checkpoint-agent.ts index ef7cf4bafe..f8832da8b3 100644 --- a/examples/headless-agent/tests/fixtures/semantic-checkpoint-agent.ts +++ b/examples/headless-agent/tests/fixtures/semantic-checkpoint-agent.ts @@ -3,7 +3,7 @@ * @module semantic-checkpoint-agent */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session' /** Fixture plugin name. */ diff --git a/examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts b/examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts index f77afe7e0a..a869ab6c9f 100644 --- a/examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts +++ b/examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts @@ -4,7 +4,7 @@ * @module subagent-diagnostic-agent */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session' /** Fixture plugin name. */ diff --git a/examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts b/examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts index 9cd3e6235f..681793a85b 100644 --- a/examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts +++ b/examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts @@ -3,7 +3,7 @@ * @module subagent-inheritance-agent */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session' /** Fixture plugin name. */ diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml index 27172b40d8..d7be47fbbe 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -3,7 +3,7 @@ # The redact-rule entry models a deployment mounting its own scrub rule on the # telemetry/record waterfall — the seam itself ships no rules. - id: logger-console - name: '@cordisjs/plugin-logger-console' + name: '@deepseek-ai/cordis-plugin-logger-console' config: colors: false levels: diff --git a/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts index 7a2aa7958a..5f205f606e 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts +++ b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' /** * Deployment-style redaction rule for the telemetry e2e: scrubs the fixture diff --git a/examples/headless-agent/tests/fixtures/time-context-mock-llm.ts b/examples/headless-agent/tests/fixtures/time-context-mock-llm.ts index 8cd3155ca7..9689dafbcf 100644 --- a/examples/headless-agent/tests/fixtures/time-context-mock-llm.ts +++ b/examples/headless-agent/tests/fixtures/time-context-mock-llm.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' /** Deterministic one-step adapter for the time-context Loader fixture. */ diff --git a/examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts b/examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts index 4cdf7d157a..ca1499d897 100644 --- a/examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts +++ b/examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts @@ -3,7 +3,7 @@ * @module workspace-context-resume-agent */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session' /** Fixture plugin name. */ diff --git a/examples/headless-agent/tests/full-loop.e2e.ts b/examples/headless-agent/tests/full-loop.e2e.ts index f4bb8695c9..a8825cc84d 100644 --- a/examples/headless-agent/tests/full-loop.e2e.ts +++ b/examples/headless-agent/tests/full-loop.e2e.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index 9b953256b7..7cf28a0dc0 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' diff --git a/examples/headless-agent/tests/resume.e2e.ts b/examples/headless-agent/tests/resume.e2e.ts index 3875a157d8..6767357b4e 100644 --- a/examples/headless-agent/tests/resume.e2e.ts +++ b/examples/headless-agent/tests/resume.e2e.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { SessionId } from '@deepseek-ai/dsh-session' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' diff --git a/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts b/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts index a8b8f02b9a..474422b4d2 100644 --- a/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts +++ b/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts @@ -1,7 +1,7 @@ import { readFile, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm' diff --git a/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts b/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts index 998c9129a5..22a3071d9c 100644 --- a/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts +++ b/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts @@ -7,7 +7,7 @@ import { readFile, readdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts index be5be2b357..76fe7b41e6 100644 --- a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts +++ b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts @@ -6,7 +6,7 @@ import { readFile, readdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/examples/headless-agent/tests/todo-write.e2e.ts b/examples/headless-agent/tests/todo-write.e2e.ts index 8b1aa9691e..014f1e3e69 100644 --- a/examples/headless-agent/tests/todo-write.e2e.ts +++ b/examples/headless-agent/tests/todo-write.e2e.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { codingHarness, TODO_SYSTEM_PROMPT, waitForIdle } from './harness.ts' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts index afe2ba325a..13d80e9fcc 100644 --- a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts +++ b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts @@ -7,7 +7,7 @@ import { createHash } from 'node:crypto' import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/examples/jsonrpc-agent/cordis.snapshot.yml b/examples/jsonrpc-agent/cordis.snapshot.yml index 6c3a6f99e7..23bc8c5402 100644 --- a/examples/jsonrpc-agent/cordis.snapshot.yml +++ b/examples/jsonrpc-agent/cordis.snapshot.yml @@ -8,7 +8,7 @@ # `llm-replay` reads `DSH_SNAPSHOT_FILE` / `DSH_SNAPSHOT_CHILD_FILES` from the # harness. Stdout remains reserved for JSON-RPC frames. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml b/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml index 498d5467f2..2b6049382f 100644 --- a/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml +++ b/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml @@ -4,7 +4,7 @@ # unowned route makes the SDK server mount the real adapter, which then demands # a key this keyless lane has no way to supply. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./persistent-tools.cordis.yml patches: diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts index 30f480064e..b693787968 100644 --- a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts index e5ce753fd8..e0a3664487 100644 --- a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' diff --git a/examples/package.json b/examples/package.json index 4e684d9d80..64b1ffdf3d 100644 --- a/examples/package.json +++ b/examples/package.json @@ -5,10 +5,10 @@ "type": "module", "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", "dependencies": { - "@cordisjs/plugin-hmr": "workspace:*", - "@cordisjs/plugin-include": "workspace:*", - "@cordisjs/plugin-logger-console": "workspace:*", - "@cordisjs/plugin-timer": "workspace:*", + "@deepseek-ai/cordis-plugin-hmr": "workspace:*", + "@deepseek-ai/cordis-plugin-include": "workspace:*", + "@deepseek-ai/cordis-plugin-logger-console": "workspace:*", + "@deepseek-ai/cordis-plugin-timer": "workspace:*", "@deepseek-ai/dsh-acp-demo": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", diff --git a/knip.json b/knip.json index 512ec350e4..930504f478 100644 --- a/knip.json +++ b/knip.json @@ -67,7 +67,6 @@ "**/*.ts" ], "ignoreDependencies": [ - "@cordisjs/plugin-logger-console", "@deepseek-ai/.+" ] }, @@ -747,8 +746,7 @@ }, "packages/bundle/base": { "ignoreDependencies": [ - "@deepseek-ai/.+", - "@cordisjs/.+" + "@deepseek-ai/.+" ] }, "packages/bundle/headless": { diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 7aa9f8c4ef..0d83ca8c24 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -26,14 +26,14 @@ "license": "BSD-3-Clause", "dependencies": { "@agentclientprotocol/sdk": "0.25.1", - "schemastery": "^3.17.0" + "@deepseek-ai/schemastery": "^3.17.0" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -44,6 +44,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index ce00fb105a..13b24feb42 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -9,11 +9,11 @@ * @module @deepseek-ai/dsh-acp */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { randomUUID } from 'node:crypto' import { isAbsolute } from 'node:path' import { Readable, Writable } from 'node:stream' -import Schema from 'schemastery' +import Schema from '@deepseek-ai/schemastery' import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import { AgentSideConnection, diff --git a/packages/acp/acp/src/invariant.ts b/packages/acp/acp/src/invariant.ts index 9d5b769872..d4db1c964c 100644 --- a/packages/acp/acp/src/invariant.ts +++ b/packages/acp/acp/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp' diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index aa3564ea8a..c5b03c39a2 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -1,6 +1,6 @@ /** In-memory ACP transport fixture over the real agent factory and loop. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { ClientSideConnection, ndJsonStream, diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 0b76b607cd..9720c8530a 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -55,14 +55,14 @@ "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-typert-registry": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "zod": "^4.4.3" } } diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index e49e9e5822..847998f173 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -4,8 +4,8 @@ * participates in method lookup, invocation, or type exposure. */ -import { Service } from 'cordis' -import type { Context } from 'cordis' +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, @@ -53,7 +53,7 @@ interface RemoteNamespaceHandle { /** Typed Remote service augmented by generated direct namespaces. */ export type ClientRemote = TypeRTClientRemote -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Generated Remote namespaces selected by the Client assembly. */ remote: ClientRemote diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 5899f5d560..ee4b063622 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-api-gateway */ -import { Context, Service, symbols } from 'cordis' +import { Context, Service, symbols } from '@deepseek-ai/cordis' import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import { remoteMethods, diff --git a/packages/api/gateway/src/invariant.ts b/packages/api/gateway/src/invariant.ts index 711c4edab5..a09d2fa776 100644 --- a/packages/api/gateway/src/invariant.ts +++ b/packages/api/gateway/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-api-gateway' diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 0917ba2ca6..7a63c8c45a 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -46,7 +46,7 @@ export interface TypertGateway { invoke(request: InvokeRemoteRequest): Promise<unknown> } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Host dispatcher for TypeRT Remote calls. */ typertGateway: TypertGateway diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 641ea81ebc..b253547c7d 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -1,4 +1,4 @@ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' diff --git a/packages/api/gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts index 4fa0ea80ad..38be5c822d 100644 --- a/packages/api/gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -1,7 +1,7 @@ import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' import { describe, expect, it } from 'vitest' -import { Context, Service, symbols } from 'cordis' +import { Context, Service, symbols } from '@deepseek-ai/cordis' import { z } from 'zod' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 3112a1e7c6..2a1d92b82a 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -52,7 +52,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-typert-registry": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -61,6 +61,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts index db765f3dde..c7b4018546 100644 --- a/packages/api/remotes/src/agent-lookup.ts +++ b/packages/api/remotes/src/agent-lookup.ts @@ -1,6 +1,6 @@ /** Host BFF policy for resolving Remote Agent and Session identities. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-persistence' diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index be92b02d77..ce67fb05d7 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -1,13 +1,13 @@ /** Platform-neutral assembly of generated Host Remote contributions. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import goalsRemote from '@deepseek-ai/dsh-goal/remote' import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' export type {} from '@deepseek-ai/dsh-goal/remote' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Generated Remote namespaces selected by this Client assembly. */ remote: TypeRTClientRemote diff --git a/packages/api/remotes/src/invariant.ts b/packages/api/remotes/src/invariant.ts index 3310bed11f..f93b63e98b 100644 --- a/packages/api/remotes/src/invariant.ts +++ b/packages/api/remotes/src/invariant.ts @@ -1,7 +1,7 @@ /** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes' diff --git a/packages/api/remotes/tests/agent-lookup.spec.ts b/packages/api/remotes/tests/agent-lookup.spec.ts index 7179f5b2b2..ed403f7f4d 100644 --- a/packages/api/remotes/tests/agent-lookup.spec.ts +++ b/packages/api/remotes/tests/agent-lookup.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index af584cba7f..be76cd148c 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -45,7 +45,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { }).map(([key, path]) => [key, artifactUrl(path)])) const script = ` import { createServer } from 'node:http' - import * as cordis from 'cordis' + import * as cordis from '@deepseek-ai/cordis' const urls = ${JSON.stringify(urls)} const { Context } = cordis @@ -123,7 +123,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const handoff = handoffs.get(id) if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id) return handoff.factory(specifier => { - if (specifier === 'cordis') return cordis + if (specifier === '@deepseek-ai/cordis') return cordis throw new Error('unexpected Client external ' + specifier) }) } diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 1834ef2d90..b11180f133 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -18,16 +18,16 @@ "@deepseek-ai/dsh-attachment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "^3.18.0", "sharp": "^0.35.3" }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 7ed4824ef2..3d67041ea4 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -1,8 +1,8 @@ /** Local durable attachment backend rooted below `DSH_HOME`. @module @deepseek-ai/dsh-attachment-local */ import { join, resolve } from 'node:path' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-paths' diff --git a/packages/attachment/attachment-local/src/invariant.ts b/packages/attachment/attachment-local/src/invariant.ts index eb14a84af6..2e37667801 100644 --- a/packages/attachment/attachment-local/src/invariant.ts +++ b/packages/attachment/attachment-local/src/invariant.ts @@ -1,7 +1,7 @@ /** Package-owned invariant companion for `@deepseek-ai/dsh-attachment-local`. @module @deepseek-ai/dsh-attachment-local/invariant */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-attachment-local' diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 8aad68d2ff..e196966fa4 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { existsSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 66cecc1894..4ec0fee955 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -17,11 +17,11 @@ "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 9b3b8dd92b..d2dc2dbd86 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -1,6 +1,6 @@ /** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { ImageAttachmentLimits, ImageAttachmentRef, @@ -19,7 +19,7 @@ export type { StoredImageAttachment, } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { attachments: AttachmentStore } diff --git a/packages/attachment/attachment/src/invariant.ts b/packages/attachment/attachment/src/invariant.ts index 2c00d56ece..a44607b093 100644 --- a/packages/attachment/attachment/src/invariant.ts +++ b/packages/attachment/attachment/src/invariant.ts @@ -1,7 +1,7 @@ /** Package-owned invariant companion for `@deepseek-ai/dsh-attachment`. @module @deepseek-ai/dsh-attachment/invariant */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-attachment' diff --git a/packages/bash/bash-env/README.i18n.yaml b/packages/bash/bash-env/README.i18n.yaml index e09b4b1405..2845459293 100644 --- a/packages/bash/bash-env/README.i18n.yaml +++ b/packages/bash/bash-env/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/bash-env/README.md -README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f -README.zh.md: 4d80d9d34f2be18e07d57d2427eb841f61f1ccfc +README.md: 54758a91773aad723c6fbbebe2ffe25aedb38599 +README.zh.md: e0663b003e94942c76cde4fb7867ed0ce74c2aa2 diff --git a/packages/bash/bash-env/README.md b/packages/bash/bash-env/README.md index 7b939326d4..54758a9177 100644 --- a/packages/bash/bash-env/README.md +++ b/packages/bash/bash-env/README.md @@ -22,7 +22,7 @@ Every foreground and background model shell call receives a newly collected trus `ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; this plugin's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-bash-env' export const inject = ['bashEnv'] diff --git a/packages/bash/bash-env/README.zh.md b/packages/bash/bash-env/README.zh.md index 4d80d9d34f..e0663b003e 100644 --- a/packages/bash/bash-env/README.zh.md +++ b/packages/bash/bash-env/README.zh.md @@ -22,7 +22,7 @@ `ctx.bashEnv` 负责收集。其他插件可以注册一个受 effect 作用域约束的 contributor,带有稳定名称、已声明的键/描述以及 `resolve(execution: ToolExecution)`;重复所有权与未声明的运行时键会响亮失败,而 `list()` 只枚举声明、不执行 provider。Harness 内置键保留 `DSH_HOME`、`DSH_SHELL` 与 `DSH_SESSION_ID`;本插件的持久化翻译器通过读取与后端无关的 `sessionPersistence.locate()` seam 拥有 `DSH_SESSION_JSONL`。 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-bash-env' export const inject = ['bashEnv'] diff --git a/packages/bash/bash-env/package.json b/packages/bash/bash-env/package.json index 33243ab344..b2de167a64 100644 --- a/packages/bash/bash-env/package.json +++ b/packages/bash/bash-env/package.json @@ -30,10 +30,10 @@ "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -43,6 +43,6 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/bash-env/src/index.ts b/packages/bash/bash-env/src/index.ts index c7caa89f08..6bdbbc2623 100644 --- a/packages/bash/bash-env/src/index.ts +++ b/packages/bash/bash-env/src/index.ts @@ -8,15 +8,15 @@ * @module @deepseek-ai/dsh-bash-env */ -import { Service, type Context } from 'cordis' -import z from 'schemastery' +import { Service, type Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-session-persistence' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { bashEnv: BashEnvRegistry } diff --git a/packages/bash/bash-env/src/invariant.ts b/packages/bash/bash-env/src/invariant.ts index 31f842c56d..fff86f91c1 100644 --- a/packages/bash/bash-env/src/invariant.ts +++ b/packages/bash/bash-env/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash-env' diff --git a/packages/bash/bash-env/tests/bash-env.spec.ts b/packages/bash/bash-env/tests/bash-env.spec.ts index eb482fd9d3..ab7905fa13 100644 --- a/packages/bash/bash-env/tests/bash-env.spec.ts +++ b/packages/bash/bash-env/tests/bash-env.spec.ts @@ -7,7 +7,7 @@ import { homedir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ToolExecution } from '@deepseek-ai/dsh-tools' diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index c3c3a5c3e6..2a19e4da5d 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -29,10 +29,10 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", @@ -40,6 +40,6 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 3d3ca833bc..2c8e200fc3 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -9,8 +9,8 @@ * @module @deepseek-ai/dsh-bash-local */ -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' diff --git a/packages/bash/bash-local/src/invariant.ts b/packages/bash/bash-local/src/invariant.ts index 3cc7bd62e2..55905cbf5e 100644 --- a/packages/bash/bash-local/src/invariant.ts +++ b/packages/bash/bash-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash-local' diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index af5566086f..607eb48dd1 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index a771acb1d3..b37f9dc48e 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -30,7 +30,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", @@ -40,7 +40,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "@deepseek-ai/node-addon-landlock-run": "workspace:*" } } diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index dc299f49c3..95e8807e1e 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-bash-sandbox */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { diff --git a/packages/bash/bash-sandbox/src/invariant.ts b/packages/bash/bash-sandbox/src/invariant.ts index b79b626033..4592d633af 100644 --- a/packages/bash/bash-sandbox/src/invariant.ts +++ b/packages/bash/bash-sandbox/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash-sandbox' diff --git a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts index 437078440c..2e6567b8d8 100644 --- a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index 7255ee43c9..e9c25b13e5 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' diff --git a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts index 9176ac320b..7bb63eeb57 100644 --- a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts +++ b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts @@ -8,7 +8,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 9c3eceda1e..a0dbec3645 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -9,7 +9,7 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index c03e407986..e3d91fb33a 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 71e9ed8d9f..d184a18d7b 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -28,12 +28,12 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 73f3d7b519..30d5840052 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-bash */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts' @@ -25,7 +25,7 @@ export type { export { parseExitStatus } from './render.ts' export type { ParsedExitStatus } from './render.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { bash: BashExecutor } diff --git a/packages/bash/bash/src/invariant.ts b/packages/bash/bash/src/invariant.ts index acf00f48a4..35f0382061 100644 --- a/packages/bash/bash/src/invariant.ts +++ b/packages/bash/bash/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned invariant companion for the bash seam. @module @deepseek-ai/dsh-bash/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash' diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index cacfe85eca..2306c0293d 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' diff --git a/packages/bash/pwsh-local/package.json b/packages/bash/pwsh-local/package.json index f65d524904..ecfa7f11da 100644 --- a/packages/bash/pwsh-local/package.json +++ b/packages/bash/pwsh-local/package.json @@ -29,10 +29,10 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", @@ -40,6 +40,6 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 5983500772..16f5c4479f 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -13,8 +13,8 @@ * @module @deepseek-ai/dsh-pwsh-local */ -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' diff --git a/packages/bash/pwsh-local/src/invariant.ts b/packages/bash/pwsh-local/src/invariant.ts index 4bb1c1ea30..9b6db9426b 100644 --- a/packages/bash/pwsh-local/src/invariant.ts +++ b/packages/bash/pwsh-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-local' diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index ef5e972fc0..6f38af8153 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -14,7 +14,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import SubprocessService from '@deepseek-ai/dsh-subprocess' diff --git a/packages/bash/pwsh-sandbox/package.json b/packages/bash/pwsh-sandbox/package.json index 6f2a87fc58..fbc5227912 100644 --- a/packages/bash/pwsh-sandbox/package.json +++ b/packages/bash/pwsh-sandbox/package.json @@ -30,7 +30,7 @@ "@deepseek-ai/dsh-pwsh-local": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", @@ -40,6 +40,6 @@ "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/pwsh-sandbox/src/index.ts b/packages/bash/pwsh-sandbox/src/index.ts index 7a930b7f0e..ec45571b76 100644 --- a/packages/bash/pwsh-sandbox/src/index.ts +++ b/packages/bash/pwsh-sandbox/src/index.ts @@ -12,7 +12,7 @@ * @module @deepseek-ai/dsh-pwsh-sandbox */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { diff --git a/packages/bash/pwsh-sandbox/src/invariant.ts b/packages/bash/pwsh-sandbox/src/invariant.ts index 6afda519ff..9d229d1f0d 100644 --- a/packages/bash/pwsh-sandbox/src/invariant.ts +++ b/packages/bash/pwsh-sandbox/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-sandbox' diff --git a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts index bb6f4f25e4..516895c6fa 100644 --- a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts +++ b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts @@ -12,7 +12,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node: import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' diff --git a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts index d710801004..fc4cd295a7 100644 --- a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts @@ -11,7 +11,7 @@ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index 2e304ae913..bd467f103e 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -36,10 +36,10 @@ "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -61,6 +61,6 @@ "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 69dae2915a..378b286ac7 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -8,8 +8,8 @@ * @module @deepseek-ai/dsh-tool-bash */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { isAbsolute, resolve as resolvePath } from 'node:path' import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' diff --git a/packages/bash/tool-bash/src/invariant.ts b/packages/bash/tool-bash/src/invariant.ts index 0620f0cfa9..a4ce0c34eb 100644 --- a/packages/bash/tool-bash/src/invariant.ts +++ b/packages/bash/tool-bash/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash' diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 99510dc6e5..425fa0644f 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 9a1698ac1d..f03de1ca93 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json index 042166b493..7f18585332 100644 --- a/packages/bash/tool-pwsh/package.json +++ b/packages/bash/tool-pwsh/package.json @@ -36,10 +36,10 @@ "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -58,6 +58,6 @@ "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index 9ef6650748..6f1cc14a37 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -20,8 +20,8 @@ */ import { isAbsolute, resolve as resolvePath } from 'node:path' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' diff --git a/packages/bash/tool-pwsh/src/invariant.ts b/packages/bash/tool-pwsh/src/invariant.ts index dd6370b490..aa53743ba7 100644 --- a/packages/bash/tool-pwsh/src/invariant.ts +++ b/packages/bash/tool-pwsh/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pwsh' diff --git a/packages/bash/tool-pwsh/tests/integration.spec.ts b/packages/bash/tool-pwsh/tests/integration.spec.ts index c347866f50..d2a89468ff 100644 --- a/packages/bash/tool-pwsh/tests/integration.spec.ts +++ b/packages/bash/tool-pwsh/tests/integration.spec.ts @@ -14,7 +14,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools' diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 7ecdbcd5f2..e6480652f9 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -11,7 +11,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index c33dc878b8..0295742268 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -28,32 +28,32 @@ "js-yaml": "^4.2.0" }, "peerDependencies": { - "@cordisjs/plugin-group": "^1.0.0", - "@cordisjs/plugin-hmr": "^1.0.15", - "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/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", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "peerDependenciesMeta": { - "@cordisjs/plugin-hmr": { + "@deepseek-ai/cordis-plugin-hmr": { "optional": true } }, "devDependencies": { - "@cordisjs/plugin-group": "workspace:^", - "@cordisjs/plugin-hmr": "workspace:^", - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", - "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/cordis-plugin-group": "workspace:^", + "@deepseek-ai/cordis-plugin-hmr": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@types/js-yaml": "^4.0.9", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index fa23e6f8da..9de8c7765d 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -11,17 +11,17 @@ import { readFileSync } from 'node:fs' import { parseEnv } from 'node:util' import { basename, dirname, resolve } from 'node:path' import * as yaml from 'js-yaml' -import { Context, type FiberState } from 'cordis' -import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' -import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' -import Group from '@cordisjs/plugin-group' +import { Context, type FiberState } from '@deepseek-ai/cordis' +import Loader, { type Entry, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' import { createEnvironmentSnapshot, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' -import type {} from '@cordisjs/plugin-hmr' +import type {} from '@deepseek-ai/cordis-plugin-hmr' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. import type {} from '@deepseek-ai/dsh-system-prompt' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Harness-home path resolver available to Loader `!!js` config expressions. */ dshHomePath?: typeof dshHomePath @@ -265,7 +265,7 @@ export async function watchUserPatches( /** * Load an optional patch-list file: a top-level YAML array of loader patch - * entries (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config + * entries (`@deepseek-ai/cordis-plugin-include`'s `PatchOptions`): id-targeted config * overrides and `insert` lists, with `!!js` expressions allowed. A missing * file means "no layer"; an unreadable, unparsable, or non-array file throws — * a present patch file that cannot apply is a misconfiguration and must fail @@ -305,7 +305,7 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ } /** * Parse one loader patch list: a top-level YAML array of - * `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and + * `@deepseek-ai/cordis-plugin-include` `PatchOptions` (id-targeted config overrides and * `insert` lists, `!!js` expressions allowed). Every invalid field or value throws, * because a patch file that cannot be applied at all is a misconfiguration; a * single patch whose target row is absent stays a per-entry Loader warning, so @@ -488,7 +488,7 @@ export async function mountRootInclude( ctx.loader.builtins.include = Include // `cordis:group` alongside it: a group row is how a composition gives one // `isolate` realm to a provider and its consumers together, and an agent - // preset living outside this workspace cannot resolve `@cordisjs/plugin-group` + // preset living outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` // by name. Both builtins load through the ambient module pipeline, so neither // depends on the included tree's own specifier resolution. ctx.loader.builtins.group = Group diff --git a/packages/boot/app-boot/src/invariant.ts b/packages/boot/app-boot/src/invariant.ts index 0dacba6e40..8195ecb553 100644 --- a/packages/boot/app-boot/src/invariant.ts +++ b/packages/boot/app-boot/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-app-boot' diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 486d414749..e19bb13c41 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -27,8 +27,8 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs' import { basename, dirname, join } from 'node:path' -import type { EntryOptions } from '@cordisjs/plugin-loader' -import { applyEntryPatches, type PatchOptions } from '@cordisjs/plugin-include' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { loadOverlayPatches } from './index.ts' diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index baeb98fe77..301a42cdc1 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, diff --git a/packages/boot/app-boot/tests/config-dump.spec.ts b/packages/boot/app-boot/tests/config-dump.spec.ts index 3876ec2421..ed0117b0dc 100644 --- a/packages/boot/app-boot/tests/config-dump.spec.ts +++ b/packages/boot/app-boot/tests/config-dump.spec.ts @@ -12,7 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import * as yaml from 'js-yaml' -import { entryListSchema } from '@cordisjs/plugin-include' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { loadOverlayPatches, renderConfigDump } from '../src/index.ts' const NAME = 'dsh-test-bin' diff --git a/packages/boot/app-boot/tests/config-reload.spec.ts b/packages/boot/app-boot/tests/config-reload.spec.ts index 9cbe3d1a58..ca718a65b3 100644 --- a/packages/boot/app-boot/tests/config-reload.spec.ts +++ b/packages/boot/app-boot/tests/config-reload.spec.ts @@ -8,8 +8,8 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import type { Include } from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import type { Include } from '@deepseek-ai/cordis-plugin-include' import { boot } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -391,7 +391,7 @@ describe('shipped builtins', () => { it('lets a booted composition share one isolate realm across a group of rows', async () => { // The reason `boot()` registers `cordis:group`: a composition — notably an // agent preset living outside this workspace, which cannot resolve - // `@cordisjs/plugin-group` by name — gives a provider and its consumer one + // `@deepseek-ai/cordis-plugin-group` by name — gives a provider and its consumer one // named realm so the service stays out of the root realm while remaining // visible to the rows that need it. const { ctx } = await bootTree([ diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index 82cbd71c28..c248643130 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -3,10 +3,10 @@ import { realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Hmr from '@cordisjs/plugin-hmr' -import Loader from '@cordisjs/plugin-loader' -import Timer from '@cordisjs/plugin-timer' +import { Context } from '@deepseek-ai/cordis' +import Hmr from '@deepseek-ai/cordis-plugin-hmr' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Timer from '@deepseek-ai/cordis-plugin-timer' import { describe, expect, it, vi } from 'vitest' async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise<Context> { diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 333385ee50..2e67bd08f8 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -9,10 +9,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Hmr from '@cordisjs/plugin-hmr' -import Loader from '@cordisjs/plugin-loader' -import Timer from '@cordisjs/plugin-timer' +import { Context } from '@deepseek-ai/cordis' +import Hmr from '@deepseek-ai/cordis-plugin-hmr' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Timer from '@deepseek-ai/cordis-plugin-timer' import { boot, loadOptionalPatches, diff --git a/packages/boot/app-boot/tsdown.config.ts b/packages/boot/app-boot/tsdown.config.ts index 88492d7c26..6693770892 100644 --- a/packages/boot/app-boot/tsdown.config.ts +++ b/packages/boot/app-boot/tsdown.config.ts @@ -14,6 +14,6 @@ export default defineConfig({ dts: false, clean: false, deps: { - alwaysBundle: ['@cordisjs/plugin-include'], + alwaysBundle: ['@deepseek-ai/cordis-plugin-include'], }, }) diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 66612f70fd..e5ab0d9463 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -14,10 +14,10 @@ - insert: - id: timer - name: '@cordisjs/plugin-timer' + name: '@deepseek-ai/cordis-plugin-timer' - id: hmr - name: '@cordisjs/plugin-hmr' + name: '@deepseek-ai/cordis-plugin-hmr' config: root: ['.'] diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index def597150e..272a403872 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -34,8 +34,8 @@ } }, "dependencies": { - "@cordisjs/plugin-hmr": "workspace:*", - "@cordisjs/plugin-timer": "workspace:*", + "@deepseek-ai/cordis-plugin-hmr": "workspace:*", + "@deepseek-ai/cordis-plugin-timer": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", @@ -114,10 +114,10 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bundle/base/src/invariant.ts b/packages/bundle/base/src/invariant.ts index 65365fb193..a3f9b51de0 100644 --- a/packages/bundle/base/src/invariant.ts +++ b/packages/bundle/base/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-base/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-base' diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 2da84a0931..4db8f9bdb9 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url' import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' import * as yaml from 'js-yaml' -import { entryListSchema } from '@cordisjs/plugin-include' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' describe('dsh-base bundle', () => { it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => { diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index bc461e4c72..c5090d4b93 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", @@ -41,15 +41,15 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 2ea68c441b..4b1403ca59 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -8,8 +8,8 @@ */ import { randomUUID } from 'node:crypto' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { ModelSelectionRef } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' @@ -17,7 +17,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' // Empty type import carries the loader Context merge for the settlement await. -import type {} from '@cordisjs/plugin-loader' +import type {} from '@deepseek-ai/cordis-plugin-loader' /** Stable Cordis plugin name. */ export const name = 'headless-runner' @@ -52,7 +52,7 @@ export interface HeadlessIo { exit(code: number): void } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Process-facing effects provided before the headless tree mounts. */ headlessIo?: HeadlessIo diff --git a/packages/bundle/headless/src/invariant.ts b/packages/bundle/headless/src/invariant.ts index 91e4925aa1..cd435b5fcc 100644 --- a/packages/bundle/headless/src/invariant.ts +++ b/packages/bundle/headless/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-headless/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-headless' diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index bd24a9300a..788e0a5a10 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -1,7 +1,7 @@ /** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import AgentDefaultModelService from '@deepseek-ai/dsh-agent-default-model' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 4f8b8d4318..f8cd162a11 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -74,18 +74,18 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-bash-env": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index b14635a868..286a006f28 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -11,10 +11,10 @@ */ import { createRequire } from 'node:module' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static' -import type {} from '@cordisjs/plugin-loader' +import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-bash-env' diff --git a/packages/bundle/web-app/src/invariant.ts b/packages/bundle/web-app/src/invariant.ts index a91d7cf7d1..13df7f956c 100644 --- a/packages/bundle/web-app/src/invariant.ts +++ b/packages/bundle/web-app/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-web-app/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-app' diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index c73f9817db..eeb2aefc07 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -9,7 +9,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver' import { apply, Config, internals } from '../src/index.ts' diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 1433c59655..8f7b485c09 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -37,7 +37,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "^3.18.0", "ws": "^8.21.0" }, "files": [ @@ -49,12 +49,12 @@ "peerDependencies": { "@deepseek-ai/dsh-host-webserver": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/ws": "^8.18.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 0788e4508e..99f49ff13a 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -3,7 +3,7 @@ * the shared API client, and lets the runtime object layer start the stream * controller with its sinks. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' import { FixtureApiClient } from './fixture.ts' diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index b2189501cb..11d9a2fd87 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,6 +1,6 @@ /** Host HTTP bridge for browser-client RPC. */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-attachment' // Activates the httpServer Context merge used below. import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' diff --git a/packages/client/connection/src/invariant.ts b/packages/client/connection/src/invariant.ts index 1112a4e638..78394263cf 100644 --- a/packages/client/connection/src/invariant.ts +++ b/packages/client/connection/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-connection' diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 7d3e5ff6f5..ec37719768 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -1,6 +1,6 @@ /** Host registry and HTTP adapter for generic Connection RPC channels. */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { clientRequestSchema, @@ -32,7 +32,7 @@ interface ConnectionRpcInterceptor { readonly options: ConnectionRpcHandlerOptions } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Host Connection transport and RPC registrations. */ connection: HostConnectionHandle diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 9d9bbd2f26..9c72e862e2 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -2,7 +2,7 @@ * Connection plugin browser-half apply: ctx.connection handle mounting, mode * selection off the page URL, and the single-consumer stream-loop ownership. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { apply, type ConnectionHandle } from '../src/client/index.ts' import type { RpcMessage } from '../src/client/api.ts' diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index fc69e4d7a1..54a2ab7e5c 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -2,7 +2,7 @@ import { EventEmitter, once } from 'node:events' import { createServer, request as httpRequest } from 'node:http' import { PassThrough, Readable } from 'node:stream' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 04e94c24bd..79e2789d33 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -31,21 +31,21 @@ }, "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@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", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "files": [ "lib/index.js", diff --git a/packages/client/hmr/src/client/index.ts b/packages/client/hmr/src/client/index.ts index d0558bcecc..8e440ac0de 100644 --- a/packages/client/hmr/src/client/index.ts +++ b/packages/client/hmr/src/client/index.ts @@ -61,8 +61,8 @@ * fiberless (the next rebuilt frame retries from scratch); an apply failure * leaves a FAILED fiber for the shell's status projection. Both log loudly. */ -import type { Context } from 'cordis' -import type { Entry, Loader } from '@cordisjs/plugin-loader' +import type { Context } from '@deepseek-ai/cordis' +import type { Entry, Loader } from '@deepseek-ai/cordis-plugin-loader' import type { PluginsEventFrame } from '../events.ts' import { EVENTS_ENDPOINT } from '../events.ts' diff --git a/packages/client/hmr/src/index.ts b/packages/client/hmr/src/index.ts index 848b546b13..17604133eb 100644 --- a/packages/client/hmr/src/index.ts +++ b/packages/client/hmr/src/index.ts @@ -8,8 +8,8 @@ */ import { statSync } from 'node:fs' import type { ServerResponse } from 'node:http' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' // Empty type imports carry the clientModuleHost/httpServer Context merges. import type {} from '@deepseek-ai/dsh-client-modules' import type {} from '@deepseek-ai/dsh-host-webserver' diff --git a/packages/client/hmr/src/invariant.ts b/packages/client/hmr/src/invariant.ts index cc875f3e13..1054d91430 100644 --- a/packages/client/hmr/src/invariant.ts +++ b/packages/client/hmr/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-client-hmr/invariant */ -import type { Context, Fiber } from 'cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr' diff --git a/packages/client/hmr/tests/node-half.spec.ts b/packages/client/hmr/tests/node-half.spec.ts index 5224061e61..7f0e1ded83 100644 --- a/packages/client/hmr/tests/node-half.spec.ts +++ b/packages/client/hmr/tests/node-half.spec.ts @@ -5,7 +5,7 @@ import { mkdtempSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WebBootGraph, ClientModuleHostService } from '@deepseek-ai/dsh-client-modules' import type { WebRoute, HttpServerService } from '@deepseek-ai/dsh-host-webserver' diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 0814c86b8d..dc90483e1b 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -39,7 +39,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -48,12 +48,12 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "files": [ "lib/index.js", diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index b8c06872db..3eea9eede6 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -9,7 +9,7 @@ * ui-slots): in THIS unit the map holds only this package's own merges, but * consumers merge more namespaces in and the intersection keeps them * string-typed. The rule fires on the narrow-map view, not real redundancy. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS, } from '@deepseek-ai/dsh-client-ui-slots' @@ -68,7 +68,7 @@ export interface LocaleSnapshot { revision: number } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { locale: LocaleService } diff --git a/packages/client/locale/src/index.ts b/packages/client/locale/src/index.ts index c8d7ed9f95..af1c9a3057 100644 --- a/packages/client/locale/src/index.ts +++ b/packages/client/locale/src/index.ts @@ -1,6 +1,6 @@ /** Host registration for the browser locale preference. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from './locale-settings.ts' diff --git a/packages/client/locale/src/invariant.ts b/packages/client/locale/src/invariant.ts index 96c94018f0..6c28c2353e 100644 --- a/packages/client/locale/src/invariant.ts +++ b/packages/client/locale/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-locale' diff --git a/packages/client/locale/src/locale-settings.ts b/packages/client/locale/src/locale-settings.ts index c5d0399f86..9733714502 100644 --- a/packages/client/locale/src/locale-settings.ts +++ b/packages/client/locale/src/locale-settings.ts @@ -1,6 +1,6 @@ /** Locale preference stored in the Host user-settings document. */ -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' /** Settings namespace owned by the locale plugin. */ export const LOCALE_SETTINGS_NAMESPACE = 'locale' diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index 84f4299ef2..c82e7c7cb3 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -1,7 +1,7 @@ /** locale apply wiring: service + dictionaries provision, declaration-aware * Language row registration, snapshot projection into the row store, and * recovery after an HMR collapse of the declaring entry. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { diff --git a/packages/client/locale/tests/host.spec.ts b/packages/client/locale/tests/host.spec.ts index 8fa339e660..809ed5bc9e 100644 --- a/packages/client/locale/tests/host.spec.ts +++ b/packages/client/locale/tests/host.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { diff --git a/packages/client/locale/tests/invariant.spec.ts b/packages/client/locale/tests/invariant.spec.ts index 2b362cb115..c55efa9a12 100644 --- a/packages/client/locale/tests/invariant.spec.ts +++ b/packages/client/locale/tests/invariant.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale' import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepseek-ai/dsh-client-locale/client' import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant' diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts index 2b696f6fc5..fb751cb301 100644 --- a/packages/client/locale/tests/locale.spec.ts +++ b/packages/client/locale/tests/locale.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 906ef783ed..a9d9d1de2d 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -35,10 +35,10 @@ }, "license": "BSD-3-Clause", "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "files": [ "lib/index.js", @@ -48,6 +48,6 @@ ], "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/client/modules/src/client/index.ts b/packages/client/modules/src/client/index.ts index a516d6cef9..c7efe7df9f 100644 --- a/packages/client/modules/src/client/index.ts +++ b/packages/client/modules/src/client/index.ts @@ -9,7 +9,7 @@ * a no-op against the already-registered entry. * @module @deepseek-ai/dsh-client-modules/client */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { DshWindow } from './manifest.ts' export { ClientModuleSystem } from './system.ts' diff --git a/packages/client/modules/src/client/manifest.ts b/packages/client/modules/src/client/manifest.ts index 50b2f8d985..a780525a9c 100644 --- a/packages/client/modules/src/client/manifest.ts +++ b/packages/client/modules/src/client/manifest.ts @@ -30,10 +30,10 @@ * composes the wire. */ -import type {} from 'cordis' +import type {} from '@deepseek-ai/cordis' import type { ClientModuleSystem } from './system.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The client module system the web shell builds at boot (provided by the `./client` wrapper plugin). */ modules: ClientModuleLoader diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index a0f932fa84..919c139283 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -26,9 +26,9 @@ import { readFile } from 'node:fs/promises' import type { IncomingMessage, ServerResponse } from 'node:http' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' -import { Service } from 'cordis' -import type { Context } from 'cordis' -import type {} from '@cordisjs/plugin-loader' +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' import type { WebBootEntry, WebBootGraph } from './client/manifest.ts' @@ -36,7 +36,7 @@ export type { BootManifest, BootModuleRow, BootPluginRow, WebBootEntry, WebBootGraph, } from './client/manifest.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The web plugin table (provided by the client-modules node half). */ clientModuleHost: ClientModuleHostService diff --git a/packages/client/modules/src/invariant.ts b/packages/client/modules/src/invariant.ts index ad9605f5dd..13b48be9a8 100644 --- a/packages/client/modules/src/invariant.ts +++ b/packages/client/modules/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-modules' diff --git a/packages/client/modules/tests/node-half.spec.ts b/packages/client/modules/tests/node-half.spec.ts index c2c865fd12..7a95281591 100644 --- a/packages/client/modules/tests/node-half.spec.ts +++ b/packages/client/modules/tests/node-half.spec.ts @@ -5,7 +5,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { ClientModuleHostService } from '../src/index.ts' diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 9f922ed956..6250a97ff5 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -56,7 +56,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-type-meta": "^0.0.1", "@deepseek-ai/dsh-typert-registry": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -64,8 +64,8 @@ "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", - "schemastery": "^3.18.0" + "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/schemastery": "^3.18.0" }, "files": [ "lib/index.js", diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index 25644d24ba..b32840daa0 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -15,8 +15,8 @@ * — a cold session's host Agent is already disposed while its client actx * stays alive for history viewing. */ -import { Context as CordisContext } from 'cordis' -import type { Context, Fiber } from 'cordis' +import { Context as CordisContext } from '@deepseek-ai/cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta' diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 1560f131d2..960d2038de 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -7,7 +7,7 @@ * [SessionsPort](./sessions-port.ts). Widening this interface is the * explicit act of widening what features may do to the sessions domain. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { RpcResult, SessionId, SubagentAddress, } from '@deepseek-ai/dsh-client-connection/client' diff --git a/packages/client/runtime/src/client/conversation/definition-registry.ts b/packages/client/runtime/src/client/conversation/definition-registry.ts index d43f494e1a..425f426512 100644 --- a/packages/client/runtime/src/client/conversation/definition-registry.ts +++ b/packages/client/runtime/src/client/conversation/definition-registry.ts @@ -1,4 +1,4 @@ -import { Service } from 'cordis' +import { Service } from '@deepseek-ai/cordis' /** Shared lifecycle and stable-entry storage for one Conversation Definition registry. */ export abstract class ConversationDefinitionRegistry<Definition> extends Service { diff --git a/packages/client/runtime/src/client/conversation/event-registry.ts b/packages/client/runtime/src/client/conversation/event-registry.ts index 6935ed1741..381fff81b5 100644 --- a/packages/client/runtime/src/client/conversation/event-registry.ts +++ b/packages/client/runtime/src/client/conversation/event-registry.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationNodeDefinition } from '../contract/conversation.ts' import { ConversationDefinitionRegistry } from './definition-registry.ts' diff --git a/packages/client/runtime/src/client/conversation/view-registry.ts b/packages/client/runtime/src/client/conversation/view-registry.ts index 1e2e53e141..5372b4a4db 100644 --- a/packages/client/runtime/src/client/conversation/view-registry.ts +++ b/packages/client/runtime/src/client/conversation/view-registry.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationViewDefinition } from '../contract/conversation.ts' import { ConversationDefinitionRegistry } from './definition-registry.ts' diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 0bc673b542..7dc604ff72 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,5 +1,5 @@ /** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta' import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' @@ -146,7 +146,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { /** * A slot's definition or registration set changed. diff --git a/packages/client/runtime/src/client/session-history/service.ts b/packages/client/runtime/src/client/session-history/service.ts index b5aba32bce..4705b6566d 100644 --- a/packages/client/runtime/src/client/session-history/service.ts +++ b/packages/client/runtime/src/client/session-history/service.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { HostFrame, IApiClient, MuxFrame, RpcRequest, SessionId, } from '@deepseek-ai/dsh-client-connection/client' diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 72edcea1c3..0f0f68827d 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -14,7 +14,7 @@ * tears its scope down immediately unless it is the staged one, whose scope * survives frozen (read-only view) until the stage moves on. */ -import type { Context, Fiber } from 'cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' import type { IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, WorkspaceId, } from '@deepseek-ai/dsh-client-connection/client' diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index b073bc7ef0..9c1d86987c 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -1,6 +1,6 @@ // Sessions remain resident after creation so they continue consuming mux frames off-screen. -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { diff --git a/packages/client/runtime/src/client/settings-scope.ts b/packages/client/runtime/src/client/settings-scope.ts index 91b6c7ec3a..cb7e933406 100644 --- a/packages/client/runtime/src/client/settings-scope.ts +++ b/packages/client/runtime/src/client/settings-scope.ts @@ -1,6 +1,6 @@ /** Host-backed settings-namespace synchronization for browser plugins. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, IApiClient, SettingsNamespaceView, } from '@deepseek-ai/dsh-client-connection/client' diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index e3ad848e05..e9b587bdc2 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -14,8 +14,8 @@ * holds this package's 'root' row in this compilation unit, but consumers * merge keys in; the rule fires on the narrow-map view, not on real * redundancy. */ -import { Service } from 'cordis' -import type { Context } from 'cordis' +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' import type { LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index c0f71b92db..468ae95a19 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -1,6 +1,6 @@ /** WorkspacesService projects the Workspace object manager for UI consumers. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { DirectoryListing, IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView, diff --git a/packages/client/runtime/src/invariant.ts b/packages/client/runtime/src/invariant.ts index 2b055ede0b..c11a014cd9 100644 --- a/packages/client/runtime/src/invariant.ts +++ b/packages/client/runtime/src/invariant.ts @@ -8,7 +8,7 @@ * `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty * in this compilation unit (intersection reads `never`) but consumers merge * keys in; the rule fires on the empty-map view, not on real redundancy. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 58d48affce..7c8a40e06b 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -3,7 +3,7 @@ * connection handle, stream-loop sink wiring into the object layer, and the * fiber-scoped loop teardown. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' diff --git a/packages/client/runtime/tests/conversation-registry.spec.ts b/packages/client/runtime/tests/conversation-registry.spec.ts index 19cbdf17f9..9f45b36c6a 100644 --- a/packages/client/runtime/tests/conversation-registry.spec.ts +++ b/packages/client/runtime/tests/conversation-registry.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts' diff --git a/packages/client/runtime/tests/invariant.spec.ts b/packages/client/runtime/tests/invariant.spec.ts index 708b714b2f..9e869baa44 100644 --- a/packages/client/runtime/tests/invariant.spec.ts +++ b/packages/client/runtime/tests/invariant.spec.ts @@ -3,7 +3,7 @@ * a fired key must already carry a bumped version (emission follows the * applied mutation), bogus payloads fail loud, foreign events pass. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import InvariantService from '@deepseek-ai/dsh-invariants' import * as RuntimeInvariant from '../src/invariant.ts' diff --git a/packages/client/runtime/tests/scope.spec.ts b/packages/client/runtime/tests/scope.spec.ts index f1c3847ce2..4c69b46edc 100644 --- a/packages/client/runtime/tests/scope.spec.ts +++ b/packages/client/runtime/tests/scope.spec.ts @@ -6,14 +6,14 @@ * and a subject-less root dispatch stays unfiltered. Scope-owned listeners * dispose with the fiber. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { createScope, scopeOf } from '../src/client/agents/scope.ts' const sid = (k: string): SessionId => k as SessionId -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { /** * Test-only routed probe event. diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 3b25ff849c..c80e7e78c1 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -6,7 +6,7 @@ * deferral — the stage follows list.current), binding identity, breadcrumb * projection, create. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts' diff --git a/packages/client/runtime/tests/settings-scope.spec.ts b/packages/client/runtime/tests/settings-scope.spec.ts index db980bf6d1..ae3c1db73c 100644 --- a/packages/client/runtime/tests/settings-scope.spec.ts +++ b/packages/client/runtime/tests/settings-scope.spec.ts @@ -1,5 +1,5 @@ -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { describe, expect, it, vi } from 'vitest' import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' import { diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index a796d324ee..7869500f7e 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -5,7 +5,7 @@ * contract (double install / not installed / non-root key), store instance * resolution and lifecycle on the ledger axis, and the entry-unload cascade. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { FC } from 'react' import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots' diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index e82c4cae3b..cc1f6c374c 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -4,7 +4,7 @@ * ctx 'session/preset-changed'; each established connection generation → * ctx 'connection/reset' (the forced cache-invalidation broadcast). */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index dd38a3119c..aa0f404da6 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' import { SessionsService } from '../src/client/sessions/service.ts' diff --git a/packages/client/schema-form/package.json b/packages/client/schema-form/package.json index c1cd5e8018..ee6529025c 100644 --- a/packages/client/schema-form/package.json +++ b/packages/client/schema-form/package.json @@ -20,15 +20,15 @@ }, "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "files": [ "lib/index.js", diff --git a/packages/client/schema-form/src/invariant.ts b/packages/client/schema-form/src/invariant.ts index f60f951fb5..90636e5d67 100644 --- a/packages/client/schema-form/src/invariant.ts +++ b/packages/client/schema-form/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-schema-form' diff --git a/packages/client/schema-form/src/model.ts b/packages/client/schema-form/src/model.ts index 5377012141..5cfb624eb4 100644 --- a/packages/client/schema-form/src/model.ts +++ b/packages/client/schema-form/src/model.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-client-schema-form/model */ -import Schema from 'schemastery' +import Schema from '@deepseek-ai/schemastery' /** Live schemastery node; the renderer reads only its structural relations. */ export type SchemaNode = Schema diff --git a/packages/client/schema-form/tests/invariant.spec.ts b/packages/client/schema-form/tests/invariant.spec.ts index 7f7ba10dd8..5507c23e66 100644 --- a/packages/client/schema-form/tests/invariant.spec.ts +++ b/packages/client/schema-form/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as SchemaFormInvariant from '@deepseek-ai/dsh-client-schema-form/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/schema-form/tests/model.spec.ts b/packages/client/schema-form/tests/model.spec.ts index 81e81e1992..1a95e88903 100644 --- a/packages/client/schema-form/tests/model.spec.ts +++ b/packages/client/schema-form/tests/model.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import Schema from 'schemastery' +import Schema from '@deepseek-ai/schemastery' import { deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft, } from '../src/model.ts' diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json index c93a302124..0891660b79 100644 --- a/packages/client/test-runtime/package.json +++ b/packages/client/test-runtime/package.json @@ -30,7 +30,7 @@ "@deepseek-ai/dsh-client-web-react": "^0.0.1", "@deepseek-ai/dsh-host-apiproxy": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -42,7 +42,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index 5dea393047..e94030a1f1 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -14,8 +14,8 @@ * `keyof SlotMap & string` is the declare-merge key pattern (see ui-slots): * this compilation unit sees only the runtime's 'root' row, but consumer * programs merge their own keys in; the rule fires on the narrow-map view. */ -import { Context, Inject } from 'cordis' -import type { Fiber, Plugin } from 'cordis' +import { Context, Inject } from '@deepseek-ai/cordis' +import type { Fiber, Plugin } from '@deepseek-ai/cordis' import { createElement, Fragment, useSyncExternalStore } from 'react' import type { ReactNode } from 'react' import { act, render, within } from '@testing-library/react' diff --git a/packages/client/test-runtime/src/invariant.ts b/packages/client/test-runtime/src/invariant.ts index 09ef3da6ce..bb4ea6f6f1 100644 --- a/packages/client/test-runtime/src/invariant.ts +++ b/packages/client/test-runtime/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 059cf22ebc..315efd8f13 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -1,5 +1,5 @@ /** Test-owned sessions face: the SlotsService host contract over declarative fixtures. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { AttachmentIdType } from '@deepseek-ai/dsh-attachment' import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/test-runtime/tests/invariant.spec.ts b/packages/client/test-runtime/tests/invariant.spec.ts index 837559ec81..08b7e5725c 100644 --- a/packages/client/test-runtime/tests/invariant.spec.ts +++ b/packages/client/test-runtime/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as TestRuntimeInvariant from '@deepseek-ai/dsh-client-test-runtime/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index d7252cd195..1b4ea86eea 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -32,6 +32,14 @@ const CSS_VIRTUAL_SUFFIX = '.mjs' */ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ +/** + * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below + * would read them as plugin packages. They carry no cross-plugin runtime + * identity to share — the framework itself is a platform module (external), + * while these are ordinary libraries a browser bundle inlines. + */ +const VENDORED_LIBRARY = /^@deepseek-ai\/(cosmokit|schemastery)(\/|$)/ + /** Generated descriptor/codec contribution with no shared runtime identity. */ const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/ @@ -208,6 +216,7 @@ function clientConfig(id: string, entry: string): UserConfig { resolveId(source: string) { if (!source.startsWith('@deepseek-ai/')) return null if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins + if (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point throw new Error( `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — ` diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 2f682d3de5..93facc56eb 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -49,7 +49,7 @@ "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-client-web-react": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -64,7 +64,7 @@ "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-agent-preset/src/invariant.ts b/packages/client/ui-agent-preset/src/invariant.ts index 1794763066..8420348123 100644 --- a/packages/client/ui-agent-preset/src/invariant.ts +++ b/packages/client/ui-agent-preset/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-agent-preset' diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index 23e1944948..3bd834d5e1 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -5,7 +5,7 @@ * that are already showing, so a default set from one converges the other. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-agent-preset/tests/invariant.spec.ts b/packages/client/ui-agent-preset/tests/invariant.spec.ts index 300e561856..b6206763e1 100644 --- a/packages/client/ui-agent-preset/tests/invariant.spec.ts +++ b/packages/client/ui-agent-preset/tests/invariant.spec.ts @@ -1,7 +1,7 @@ /** The package's node half: an empty host body and an explained empty invariant companion. */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import * as AgentPresetInvariant from '@deepseek-ai/dsh-client-ui-agent-preset/invariant' diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json index df3c88a678..5ff21d89b9 100644 --- a/packages/client/ui-command/package.json +++ b/packages/client/ui-command/package.json @@ -50,7 +50,7 @@ "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -64,7 +64,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts index d65ab0f78e..f8de391fad 100644 --- a/packages/client/ui-command/src/client/index.ts +++ b/packages/client/ui-command/src/client/index.ts @@ -28,7 +28,7 @@ export type { } from './contract.ts' export type { CommandKey } from './locales.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { command: CommandService } diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 866ff89c9d..00515c1e41 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -7,8 +7,8 @@ * addresses the session's agent by sessionId — sessions are always * agent-backed. */ -import { Service } from 'cordis' -import type { Context } from 'cordis' +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' import type { diff --git a/packages/client/ui-command/src/invariant.ts b/packages/client/ui-command/src/invariant.ts index 2d38b762a9..734fef9466 100644 --- a/packages/client/ui-command/src/invariant.ts +++ b/packages/client/ui-command/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-command' diff --git a/packages/client/ui-command/tests/browser-plugin.spec.ts b/packages/client/ui-command/tests/browser-plugin.spec.ts index f093548f3e..ae2afff1f5 100644 --- a/packages/client/ui-command/tests/browser-plugin.spec.ts +++ b/packages/client/ui-command/tests/browser-plugin.spec.ts @@ -6,7 +6,7 @@ * scope → popupFor; unknown id fails loud), both fold up on fiber disposal * (HMR safety), and the service satisfies the frozen CommandServiceContract. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index f7ee172b8e..b5029761fa 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -7,7 +7,7 @@ * payload, the scoped consume-token dispatch, per-session popupFor * lifecycle, and the directory invalidation event subscriptions. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 5dda7ef637..a21fad9168 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -41,7 +41,7 @@ "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", "clsx": "^2.0.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", @@ -59,7 +59,7 @@ "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-token-meter": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -86,7 +86,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index cad25cc84e..5449295055 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,5 +1,5 @@ /** Registers the conversation components, shared store, and service callbacks. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import { bindSettingsScope, resolveWorkspacePath, type ISessions, type SessionId, diff --git a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts index 8926fc2a8e..78aa36d136 100644 --- a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { NS } from '../locales.ts' import { AssistantNodeView } from './AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx' diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index bbca9254bc..36b517f5c4 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -228,7 +228,7 @@ export interface ChatFileMentions { forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */ chatFileMentions: ChatFileMentions diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts index 83df1e0df6..5baad5c37b 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts index f417c33b75..8b5a506030 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ConversationLocation, ConversationNode, ConversationTimelineSnapshot, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts index 752d7e3dd2..1517fbe718 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts index d21b4aa4b4..2852019c3f 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts b/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts index a93fc8fdd4..6309c35b94 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationNodeDefinition, UnknownSurfaceNode, } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts index e3cfae47f4..92e611f77c 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationNodeDefinition, ConversationPreviousContext, } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts index 91300944d7..d57a6d9d96 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/register.ts b/packages/client/ui-conversation/src/client/conversation-nodes/register.ts index 9102b1f2f4..bc911ad5db 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/register.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/register.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { registerAssistantConversationNode } from './assistant.ts' import { registerChatConversationView } from './chat-snapshot-builder.ts' import { registerCommandConversationNode } from './command.ts' diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts index d504f32928..31b80075a8 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationLocation, ConversationNodeDefinition, ModelRetryNode, } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts index c1b8022e41..46c838e980 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall, ToolCallBlock, ToolResultNode, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts index 60b2fca087..1f5a87add8 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode, } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts index 2a1b4d2b13..01bee27f3f 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation, } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index afd2f3ba19..5f120157ca 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -36,7 +36,7 @@ export type { } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The outward face only; the concrete service stays inside this plugin. */ conversation: import('./service.ts').IConversation diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 35dac88ffd..19cc9c62c8 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -3,7 +3,7 @@ // // The 'conversation.input.dock' SlotMap declaration lives in // ../contract/slots.ts beside the other input-region slots. -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { useEffect, useId, useMemo, useState } from 'react' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index f8d7095491..b70f58c6d9 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -7,8 +7,8 @@ * through one property read; assignment through the tracker proxy and `#` * private fields bypass that rebinding. */ -import { Service } from 'cordis' -import type { Context } from 'cordis' +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' // Type-only imports: a plugin-to-plugin value import is a bundle purity // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index da6faa5794..ad627e7cad 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -6,7 +6,7 @@ // framework-free. Visual: figma 772:51905 / 772:52972 / 772:53419. import { useId, useState } from 'react' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // The domain's client-namespace pure-type outlet: one import edge delivers // the `todos` projection-key merge (single source, no consumer-side restated diff --git a/packages/client/ui-conversation/src/index.ts b/packages/client/ui-conversation/src/index.ts index b49d7dcf0d..31754ae7e1 100644 --- a/packages/client/ui-conversation/src/index.ts +++ b/packages/client/ui-conversation/src/index.ts @@ -1,6 +1,6 @@ /** Host registration for browser conversation preferences. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { CONVERSATION_SETTINGS_NAMESPACE, ConversationSettingsSchema } from './submission-settings.ts' diff --git a/packages/client/ui-conversation/src/invariant.ts b/packages/client/ui-conversation/src/invariant.ts index 66c54081ab..7e0b5b9a9b 100644 --- a/packages/client/ui-conversation/src/invariant.ts +++ b/packages/client/ui-conversation/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-conversation' diff --git a/packages/client/ui-conversation/src/submission-settings.ts b/packages/client/ui-conversation/src/submission-settings.ts index 0bd42d33cf..1aa4d0363a 100644 --- a/packages/client/ui-conversation/src/submission-settings.ts +++ b/packages/client/ui-conversation/src/submission-settings.ts @@ -1,6 +1,6 @@ /** Busy-Enter preference stored in the Host user-settings document. */ -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' /** Settings namespace owned by the conversation plugin. */ export const CONVERSATION_SETTINGS_NAMESPACE = 'ui-conversation' diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index ad644d940b..b0ce994f44 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -3,7 +3,7 @@ // without a settings service and AssistantMarkdown reasoning/unknown block arms. import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { cleanup, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' diff --git a/packages/client/ui-conversation/tests/host.spec.ts b/packages/client/ui-conversation/tests/host.spec.ts index 0d16a23da2..0b470e8d47 100644 --- a/packages/client/ui-conversation/tests/host.spec.ts +++ b/packages/client/ui-conversation/tests/host.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index ad268611e2..416d2fe620 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -8,7 +8,7 @@ * itself is not a dependency of this package; the source below is the * decision-table contract at the `SlashSource` boundary. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { EMPTY_CHAT_SNAPSHOT, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 8454aa35c8..65b07203f9 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -3,7 +3,7 @@ // TestSessions mints tagged scopes through the production createScope, so the // service's scopeOf/binding path runs against production resolution (no local // tag probe). -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/ui-conversation/tests/views-type-chain.spec.tsx b/packages/client/ui-conversation/tests/views-type-chain.spec.tsx index b055c51560..c8d384163d 100644 --- a/packages/client/ui-conversation/tests/views-type-chain.spec.tsx +++ b/packages/client/ui-conversation/tests/views-type-chain.spec.tsx @@ -1,7 +1,7 @@ // View-ring type-chain samples. This spec pins the conversation-owned SlotMap // row, list-kind registration shape, composed view props, and the runtime // ledger projection consumed by ConversationRoot. -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { ReactNode } from 'react' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 07e8a1bc28..4b7b8daa56 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -46,7 +46,7 @@ "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -56,7 +56,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-deliverables/src/invariant.ts b/packages/client/ui-deliverables/src/invariant.ts index 39c39591cf..d14e474ca3 100644 --- a/packages/client/ui-deliverables/src/invariant.ts +++ b/packages/client/ui-deliverables/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-deliverables' diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 75f422787a..f38faf8dd5 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -5,7 +5,7 @@ * and opener wiring, and the plugin registrations' fiber-teardown removal * (HMR safety) against the real SlotsService. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 9e8b6865f9..2e5816437e 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -47,7 +47,7 @@ "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-goal": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -62,7 +62,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-goal/src/invariant.ts b/packages/client/ui-goal/src/invariant.ts index 2120600664..93d0602d25 100644 --- a/packages/client/ui-goal/src/invariant.ts +++ b/packages/client/ui-goal/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-goal' diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 756968136e..9ead151b65 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -10,7 +10,7 @@ * plugin fiber (HMR safety). The node half and the invariant companion are * exercised over the same Context. */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index ba41bc9dcb..5fc53a5fa8 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -41,7 +41,7 @@ "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-client-ui-theme": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -51,7 +51,7 @@ "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 8050cb0916..f2269500b4 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -23,7 +23,7 @@ import { ThemePresenter } from './theme-presenter.ts' export { LayoutService } from './service.ts' export type { ILayout } from './service.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The outward face only; the concrete service stays inside this plugin. */ layout: import('./service.ts').ILayout diff --git a/packages/client/ui-layout/src/invariant.ts b/packages/client/ui-layout/src/invariant.ts index dd572e679d..266b9e5b0f 100644 --- a/packages/client/ui-layout/src/invariant.ts +++ b/packages/client/ui-layout/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-layout' diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index 024382fa79..1aae24bc11 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -6,7 +6,7 @@ // and the invariant companion ride along — one-line surfaces the aggregate // coverage gate still requires exercised. -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index 6b6ed328fc..dcd139dad5 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -48,7 +48,7 @@ "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "clsx": "^2.1.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -63,7 +63,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "clsx": "^2.1.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-model/src/client/service.ts b/packages/client/ui-model/src/client/service.ts index 5bf2c744a3..5a35ce0039 100644 --- a/packages/client/ui-model/src/client/service.ts +++ b/packages/client/ui-model/src/client/service.ts @@ -12,13 +12,13 @@ * strings, and it models global+shadow named registries — this is a * per-session singleton with no global layer to merge. */ -import { Service } from 'cordis' -import type { Context } from 'cordis' +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionsService } from '@deepseek-ai/dsh-client-runtime/client' import { ModelDirectory } from './directory.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { models: ModelService } diff --git a/packages/client/ui-model/src/invariant.ts b/packages/client/ui-model/src/invariant.ts index baac6dcc70..9b24e5c3c9 100644 --- a/packages/client/ui-model/src/invariant.ts +++ b/packages/client/ui-model/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-model' diff --git a/packages/client/ui-model/tests/browser-plugin.spec.ts b/packages/client/ui-model/tests/browser-plugin.spec.ts index 422ada56b3..4c7a1430b1 100644 --- a/packages/client/ui-model/tests/browser-plugin.spec.ts +++ b/packages/client/ui-model/tests/browser-plugin.spec.ts @@ -8,7 +8,7 @@ * (and the reverse), the one-shared-state contract of the dual entry. * Scope disposal drops the directory (HMR safety). */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { createScope } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-models/package.json b/packages/client/ui-models/package.json index dc02a09aa6..fb0ff5a0c3 100644 --- a/packages/client/ui-models/package.json +++ b/packages/client/ui-models/package.json @@ -45,7 +45,7 @@ "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-client-web-react": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -60,7 +60,7 @@ "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-models/src/invariant.ts b/packages/client/ui-models/src/invariant.ts index c7c4996748..8b37c0774f 100644 --- a/packages/client/ui-models/src/invariant.ts +++ b/packages/client/ui-models/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-models' diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index 2842b94554..dc648e939b 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -1,5 +1,5 @@ /** Models section registration: slot declaration injection, the locale-following label thunk, and HMR recovery. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 09167b55cb..4d7086bd99 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -2,7 +2,7 @@ /** Section, setup-card, and hand-written editor behavior over a scripted wire face. */ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import Schema from 'schemastery' +import Schema from '@deepseek-ai/schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' import { diff --git a/packages/client/ui-models/tests/invariant.spec.ts b/packages/client/ui-models/tests/invariant.spec.ts index 8f9622b599..05362ba8ea 100644 --- a/packages/client/ui-models/tests/invariant.spec.ts +++ b/packages/client/ui-models/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-models/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' import { ModelsSection } from '../src/client/ModelsSection.tsx' diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 9ff96cdebc..a1953e3a5c 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -2,7 +2,7 @@ /** Model-list editing, endpoint interrogation, and hand-declared provider creation. */ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import Schema from 'schemastery' +import Schema from '@deepseek-ai/schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' import { ModelsSection } from '../src/client/ModelsSection.tsx' diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index 4c90851cf7..457511c928 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -49,7 +49,7 @@ "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-permission": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -65,7 +65,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-permission/src/invariant.ts b/packages/client/ui-permission/src/invariant.ts index 1c3f7d6500..2e531fe55f 100644 --- a/packages/client/ui-permission/src/invariant.ts +++ b/packages/client/ui-permission/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-permission' diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 309298a64a..4575941b4a 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -8,7 +8,7 @@ * disposal removes the contribution (HMR safety). The same plugin registers * its Settings row and invalidates that row on host settings changes. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index c9aa3fd0f1..888cc74005 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -46,7 +46,7 @@ "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-plan-mode": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -61,7 +61,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-plan/src/invariant.ts b/packages/client/ui-plan/src/invariant.ts index 82c9fc9376..acea37ddeb 100644 --- a/packages/client/ui-plan/src/invariant.ts +++ b/packages/client/ui-plan/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plan' diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts index e384ba5356..79d2903e93 100644 --- a/packages/client/ui-plan/tests/browser-plugin.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.spec.ts @@ -5,7 +5,7 @@ * outcomes into null (admitted) or a user-visible failure line; teardown * empties the seat (HMR safety). */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index bb788f7069..91d8647a55 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -45,7 +45,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "files": [ "lib/index.js", @@ -54,6 +54,6 @@ ], "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/client/ui-primitives/src/invariant.ts b/packages/client/ui-primitives/src/invariant.ts index 5ce3411aff..a92fe97731 100644 --- a/packages/client/ui-primitives/src/invariant.ts +++ b/packages/client/ui-primitives/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-primitives' diff --git a/packages/client/ui-primitives/tests/invariant.spec.ts b/packages/client/ui-primitives/tests/invariant.spec.ts index 72e5cb2f7c..122524380a 100644 --- a/packages/client/ui-primitives/tests/invariant.spec.ts +++ b/packages/client/ui-primitives/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as PrimitivesInvariant from '@deepseek-ai/dsh-client-ui-primitives/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index 874a634f7c..80f18e3a2a 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -48,7 +48,7 @@ "peerDependencies": { "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -58,7 +58,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-question/src/invariant.ts b/packages/client/ui-question/src/invariant.ts index 6a6e7ebb90..4da42322c3 100644 --- a/packages/client/ui-question/src/invariant.ts +++ b/packages/client/ui-question/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-question' diff --git a/packages/client/ui-question/tests/browser-plugin.spec.ts b/packages/client/ui-question/tests/browser-plugin.spec.ts index 01b077a29a..43e7673da9 100644 --- a/packages/client/ui-question/tests/browser-plugin.spec.ts +++ b/packages/client/ui-question/tests/browser-plugin.spec.ts @@ -6,7 +6,7 @@ * domain-face behavior is covered props-direct in question-composer.spec.tsx; * no renderer machinery here. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-question/tests/node-plugin.spec.ts b/packages/client/ui-question/tests/node-plugin.spec.ts index 4602ef0bed..29f137ebfe 100644 --- a/packages/client/ui-question/tests/node-plugin.spec.ts +++ b/packages/client/ui-question/tests/node-plugin.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' import ToolRegistry from '@deepseek-ai/dsh-tools' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 95166e6d66..a66cb41153 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -40,7 +40,7 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-client-connection": "^0.0.1", @@ -51,7 +51,7 @@ "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-client-web-react": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -65,7 +65,7 @@ "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-settings-general/src/index.ts b/packages/client/ui-settings-general/src/index.ts index 18518c2835..0cea245db5 100644 --- a/packages/client/ui-settings-general/src/index.ts +++ b/packages/client/ui-settings-general/src/index.ts @@ -1,7 +1,7 @@ /** Host loader entry for the browser implementation exported from `./client`. */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts index c5917bd83b..adfd582ca6 100644 --- a/packages/client/ui-settings-general/src/invariant.ts +++ b/packages/client/ui-settings-general/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-general' diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index ae06c22c03..4d7301faa1 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,5 +1,5 @@ /** Ownerless-copy registrations: the six seats, dictionaries, thunked labels, and HMR recovery. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-settings-general/tests/host.spec.ts b/packages/client/ui-settings-general/tests/host.spec.ts index 6434bc833a..e78d0e006a 100644 --- a/packages/client/ui-settings-general/tests/host.spec.ts +++ b/packages/client/ui-settings-general/tests/host.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { apply } from '../src/index.ts' diff --git a/packages/client/ui-settings-general/tests/invariant.spec.ts b/packages/client/ui-settings-general/tests/invariant.spec.ts index 59863a5794..343b09b547 100644 --- a/packages/client/ui-settings-general/tests/invariant.spec.ts +++ b/packages/client/ui-settings-general/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as GeneralInvariant from '@deepseek-ai/dsh-client-ui-settings-general/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 9383ca0205..138b4e3c88 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -44,7 +44,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -57,7 +57,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react-dom": "~18.3.0", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-settings/src/invariant.ts b/packages/client/ui-settings/src/invariant.ts index 53d7fb066a..f78b25e91c 100644 --- a/packages/client/ui-settings/src/invariant.ts +++ b/packages/client/ui-settings/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings' diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index 3133ad4d76..2e8243fa26 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -1,5 +1,5 @@ /** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client' diff --git a/packages/client/ui-settings/tests/invariant.spec.ts b/packages/client/ui-settings/tests/invariant.spec.ts index c3474d5bdd..f9824921a6 100644 --- a/packages/client/ui-settings/tests/invariant.spec.ts +++ b/packages/client/ui-settings/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as SettingsInvariant from '@deepseek-ai/dsh-client-ui-settings/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index d40bcabcdb..7a135c1bcc 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -46,7 +46,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -58,7 +58,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-sidebar/src/invariant.ts b/packages/client/ui-sidebar/src/invariant.ts index 52d69c5ce2..94e26021a8 100644 --- a/packages/client/ui-sidebar/src/invariant.ts +++ b/packages/client/ui-sidebar/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-sidebar' diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index ccd997be76..31fcddfe9f 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -1,5 +1,5 @@ /** Sidebar shell slot registration and its plain runtime/layout callbacks. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-sidebar/tests/invariant.spec.ts b/packages/client/ui-sidebar/tests/invariant.spec.ts index c524606dd5..11a8d66a08 100644 --- a/packages/client/ui-sidebar/tests/invariant.spec.ts +++ b/packages/client/ui-sidebar/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as SidebarInvariant from '@deepseek-ai/dsh-client-ui-sidebar/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 68a2dc2667..0490958e7d 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -47,7 +47,7 @@ "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -62,7 +62,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-skill/src/invariant.ts b/packages/client/ui-skill/src/invariant.ts index 9246466cd1..718a9586a1 100644 --- a/packages/client/ui-skill/src/invariant.ts +++ b/packages/client/ui-skill/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-skill' diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 5e143c0b96..844333dd81 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -13,7 +13,7 @@ * projections. Direct driving is deliberate: this spec owns only the * source's own contract. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-slash/package.json b/packages/client/ui-slash/package.json index 01e48dcdf6..7e99d22875 100644 --- a/packages/client/ui-slash/package.json +++ b/packages/client/ui-slash/package.json @@ -45,7 +45,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -56,7 +56,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-slash/src/client/index.ts b/packages/client/ui-slash/src/client/index.ts index bcc20a8679..54f7a96dda 100644 --- a/packages/client/ui-slash/src/client/index.ts +++ b/packages/client/ui-slash/src/client/index.ts @@ -27,7 +27,7 @@ export type { export type { DetectTrigger, ExactMatch, MenuEvent, MenuReduce, MenuState, TriggerHit } from '../core/contract.ts' export type { SlashServiceContract } from './contract.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The outward face only; the concrete service stays inside this plugin. */ slash: import('./contract.ts').SlashServiceContract diff --git a/packages/client/ui-slash/src/client/service.ts b/packages/client/ui-slash/src/client/service.ts index d9af10e887..35e6153a9b 100644 --- a/packages/client/ui-slash/src/client/service.ts +++ b/packages/client/ui-slash/src/client/service.ts @@ -5,8 +5,8 @@ * {@link SlashController}; the service only registers sources, resolves * controllers by session scope, and relays roster changes. */ -import { Service } from 'cordis' -import type { Context } from 'cordis' +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashSource } from '../types.ts' import { SlashController } from './controller.ts' diff --git a/packages/client/ui-slash/src/invariant.ts b/packages/client/ui-slash/src/invariant.ts index a83b4841a1..fb9102be3f 100644 --- a/packages/client/ui-slash/src/invariant.ts +++ b/packages/client/ui-slash/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-slash' diff --git a/packages/client/ui-slash/src/types.ts b/packages/client/ui-slash/src/types.ts index c62ca520d7..1266bc15da 100644 --- a/packages/client/ui-slash/src/types.ts +++ b/packages/client/ui-slash/src/types.ts @@ -220,7 +220,7 @@ export interface InsertTextRequest { readonly span: TokenSpan } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { /** * Applies one command claim to the scoped Input. Dispatched with the diff --git a/packages/client/ui-slash/tests/apply.spec.ts b/packages/client/ui-slash/tests/apply.spec.ts index c8d65f10c8..b447cd12d0 100644 --- a/packages/client/ui-slash/tests/apply.spec.ts +++ b/packages/client/ui-slash/tests/apply.spec.ts @@ -4,7 +4,7 @@ * registration follows the slot declaration, resolves the per-session controller from the slot's * sessionId, and unregisters on fiber teardown. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/ui-slash/tests/service.spec.ts b/packages/client/ui-slash/tests/service.spec.ts index 143f6e6ddf..c642ec8001 100644 --- a/packages/client/ui-slash/tests/service.spec.ts +++ b/packages/client/ui-slash/tests/service.spec.ts @@ -7,7 +7,7 @@ * scope-birth roster warm — is SlashController behavior, tested on a real * session scope (createScope). */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 9c459fd0f8..ef809470b5 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -22,7 +22,7 @@ "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "files": [ "lib/index.js", @@ -31,6 +31,6 @@ ], "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/client/ui-slots/src/invariant.ts b/packages/client/ui-slots/src/invariant.ts index de9ea2c511..d3cd66c5ed 100644 --- a/packages/client/ui-slots/src/invariant.ts +++ b/packages/client/ui-slots/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-slots' diff --git a/packages/client/ui-slots/tests/invariant.spec.ts b/packages/client/ui-slots/tests/invariant.spec.ts index 3b5740924f..72d2f32fc8 100644 --- a/packages/client/ui-slots/tests/invariant.spec.ts +++ b/packages/client/ui-slots/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as SlotsInvariant from '@deepseek-ai/dsh-client-ui-slots/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 43a9c35a42..6c9a178d31 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -52,7 +52,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-token-meter": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -66,7 +66,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-subagent/src/invariant.ts b/packages/client/ui-subagent/src/invariant.ts index 645f88c9b6..b96fefa21c 100644 --- a/packages/client/ui-subagent/src/invariant.ts +++ b/packages/client/ui-subagent/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-subagent' diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 78d04489ab..426dadaa60 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -11,7 +11,7 @@ * projections. Direct driving is deliberate: this spec owns only the * source's own contract. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { SlotsService, type ConversationSnapshot, type SessionId, type SessionListState, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index dbc65c11e9..27f1799c56 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -42,7 +42,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -53,7 +53,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "files": [ @@ -70,6 +70,6 @@ "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", "clsx": "^2.0.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" } } diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 8ef7f3fee7..aaa37ecca8 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -7,7 +7,7 @@ * document. The plugin also registers the Appearance preference row into the * settings General section — the theme feature owns its own settings surface. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import { bindSettingsScope, type ClientContext, type SettingsScope, @@ -66,7 +66,7 @@ export interface ThemeSnapshot { revision: number } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { theme: ThemeService } diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index 576028d37d..8b8e0c6d30 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -1,6 +1,6 @@ /** Host registration for the browser theme preference. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from './theme-settings.ts' diff --git a/packages/client/ui-theme/src/invariant.ts b/packages/client/ui-theme/src/invariant.ts index 51667dc5a9..6463b72f5f 100644 --- a/packages/client/ui-theme/src/invariant.ts +++ b/packages/client/ui-theme/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-theme' diff --git a/packages/client/ui-theme/src/theme-settings.ts b/packages/client/ui-theme/src/theme-settings.ts index d7fc966031..c2c268e21f 100644 --- a/packages/client/ui-theme/src/theme-settings.ts +++ b/packages/client/ui-theme/src/theme-settings.ts @@ -1,6 +1,6 @@ /** Theme preferences stored in the Host user-settings document. */ -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' /** Built-in preferences accepted at the registry and settings boundaries. */ export const THEME_PREFERENCES = ['light', 'dark', 'system'] as const diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index 25c2ac14df..006d64d621 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -1,7 +1,7 @@ /** ui-theme apply wiring: service provision, settings dictionaries riding the * locale service, declaration-aware Appearance row registration, snapshot * projection into the row store, and HMR collapse recovery. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-theme/tests/host.spec.ts b/packages/client/ui-theme/tests/host.spec.ts index 6cbbd91c27..0e99445892 100644 --- a/packages/client/ui-theme/tests/host.spec.ts +++ b/packages/client/ui-theme/tests/host.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.spec.ts index c5eedc9dd7..1e7e4a75ba 100644 --- a/packages/client/ui-theme/tests/invariant.spec.ts +++ b/packages/client/ui-theme/tests/invariant.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme' import { apply as clientApply, inject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant' diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index b7fc3bd17a..774b7dd10a 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import type { ThemeSettings, ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index c40b09abdb..2db30e5d48 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -47,7 +47,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -62,7 +62,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx index 880938567f..a8a7a6497b 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx @@ -7,7 +7,7 @@ // render in the composer takeover. import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' import { toolRowModel } from '../models/tool-call-model.ts' diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index 71a8503e69..dc9a02a901 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -14,7 +14,7 @@ // collapsed summary is the failure's first line in the error color. import { useState, type KeyboardEvent } from 'react' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import clsx from 'clsx' import { IconApiOutline14, IconChevronDownOutline14, IconInspectOutline12, StateDot, TerminalBlock, diff --git a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx index 8dd7a37306..9616dab7cd 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx @@ -8,7 +8,7 @@ // `result.isError`) keeps the model-facing error text on ToolRow's Output // section, its first line in the collapsed summary. -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { IconEditOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx index c404f535e5..c066b6667a 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx @@ -7,7 +7,7 @@ // yet) and a non-read result render the summary row alone: the read intent is // result-side only, so there is no running-state read card to draw. -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx index 10fea1b8c8..47b17b6fa8 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx @@ -11,7 +11,7 @@ // nested run_code sub-dispatch, a legacy generic result) surfaces its // model-facing text through ToolRow's Output section instead. -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx index 111189aede..143a43ee29 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx @@ -8,7 +8,7 @@ // above the composer, so the row stays one line until expanded. import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' import { toolRowModel } from '../models/tool-call-model.ts' diff --git a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx index 80489c478f..c0e546f071 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx @@ -9,7 +9,7 @@ // no web card (the tools keep a generic pending view), so a running row is the // summary line alone. -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { IconBrowseOutline16, IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' diff --git a/packages/client/ui-tool/src/invariant.ts b/packages/client/ui-tool/src/invariant.ts index bfee949195..e00671b0d7 100644 --- a/packages/client/ui-tool/src/invariant.ts +++ b/packages/client/ui-tool/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-tool' diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx index df5dcf6718..1a7dd2d892 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx @@ -8,7 +8,7 @@ // and a file sub-row click opens the host path. Running parents // (runningCalls) nest their so-far dispatches the same way. -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.spec.tsx index 012ae754b2..ef00460103 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -8,7 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.spec.tsx index 927b760b82..44f63147a0 100644 --- a/packages/client/ui-tool/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.spec.tsx @@ -294,7 +294,7 @@ describe('web toolview registration', () => { return () => {} }, }, - } as unknown as import('cordis').Context + } as unknown as import('@deepseek-ai/cordis').Context webToolview.apply(ctx) expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch']) // Both keys claim the conversation locale seat ToolRow's body copy needs. diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 9ed4b550c1..6f85a51c8a 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -44,7 +44,7 @@ "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -56,7 +56,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index a6b5a4282d..e1d3a5cc17 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -2,7 +2,7 @@ * Browser trajectory plugin contributing one entry to the conversation view * slot without defining a service. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: the 'conversation.view' SlotMap row (declared by the slot's // owning package) must be in the program for the register calls to type. diff --git a/packages/client/ui-trajectory/src/invariant.ts b/packages/client/ui-trajectory/src/invariant.ts index 11e56bb058..7a43d872cc 100644 --- a/packages/client/ui-trajectory/src/invariant.ts +++ b/packages/client/ui-trajectory/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-trajectory' diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 579a97337a..511cf03b5c 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -8,7 +8,7 @@ */ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index c9952be580..26adf85ba8 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -7,7 +7,7 @@ * event ledger with its timing overview, and fiber disposal removes the tab. * Timeline projection and inclusive focus edge cases ride along. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 197369cd48..5607c8cc40 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -47,7 +47,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "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", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-workspace/src/invariant.ts b/packages/client/ui-workspace/src/invariant.ts index d3f0df2cdc..4a15d37998 100644 --- a/packages/client/ui-workspace/src/invariant.ts +++ b/packages/client/ui-workspace/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-workspace' diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index d6dfe8d185..44645a9ee3 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-workspace/tests/invariant.spec.ts b/packages/client/ui-workspace/tests/invariant.spec.ts index 0606c94e09..8773f754df 100644 --- a/packages/client/ui-workspace/tests/invariant.spec.ts +++ b/packages/client/ui-workspace/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as WorkspaceInvariant from '@deepseek-ai/dsh-client-ui-workspace/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/web-react/package.json b/packages/client/web-react/package.json index b5e242a00e..5aeae6f06f 100644 --- a/packages/client/web-react/package.json +++ b/packages/client/web-react/package.json @@ -26,12 +26,12 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "files": [ "lib/index.js", diff --git a/packages/client/web-react/src/invariant.ts b/packages/client/web-react/src/invariant.ts index aff9c09cd3..6d4fec6566 100644 --- a/packages/client/web-react/src/invariant.ts +++ b/packages/client/web-react/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-web-react' diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 847d126386..348f1ce1cb 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -30,19 +30,19 @@ "react-dom": "^18.2.0" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "typescript": "^6.0.3" }, "peerDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "files": [ "lib/index.js", diff --git a/packages/client/web/src/app-shell.ts b/packages/client/web/src/app-shell.ts index 140fd49c60..3ab76c54ba 100644 --- a/packages/client/web/src/app-shell.ts +++ b/packages/client/web/src/app-shell.ts @@ -3,7 +3,7 @@ * graph and shell registry; there is no npm package behind it. */ import type { ReactNode } from 'react' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react' import { buildRenderApp } from './app.tsx' @@ -16,7 +16,7 @@ export interface AppShellService { renderApp: () => ReactNode } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The shell assembly face, provided by the app-shell entry once its inject set is active. */ appShell: AppShellService diff --git a/packages/client/web/src/app.tsx b/packages/client/web/src/app.tsx index 5e0010301c..646dcec3dc 100644 --- a/packages/client/web/src/app.tsx +++ b/packages/client/web/src/app.tsx @@ -6,7 +6,7 @@ * the program. */ import type { ReactNode } from 'react' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { DocumentTitle } from './DocumentTitle.tsx' // Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program. diff --git a/packages/client/web/src/boot.tsx b/packages/client/web/src/boot.tsx index c1203929f9..7afc298767 100644 --- a/packages/client/web/src/boot.tsx +++ b/packages/client/web/src/boot.tsx @@ -32,8 +32,8 @@ * decisions (the app-shell assembly is itself a graph entry, the only * shell-own module registered with the module system). */ -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { createRoot, type Root } from 'react-dom/client' import * as ModulesClient from '@deepseek-ai/dsh-client-modules/client' import { diff --git a/packages/client/web/src/invariant.ts b/packages/client/web/src/invariant.ts index 7b9fa6292c..8964082cc8 100644 --- a/packages/client/web/src/invariant.ts +++ b/packages/client/web/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-web' diff --git a/packages/client/web/src/loader-status.ts b/packages/client/web/src/loader-status.ts index 03ac761afe..8f8d250ef0 100644 --- a/packages/client/web/src/loader-status.ts +++ b/packages/client/web/src/loader-status.ts @@ -10,7 +10,7 @@ * the loading page has to work while (and especially when) plugins fail. * @module @deepseek-ai/dsh-client-web/src/loader-status */ -import type { FiberState } from 'cordis' +import type { FiberState } from '@deepseek-ai/cordis' /** * Value mirror of cordis's `FiberState` const enum: a const enum has no diff --git a/packages/client/web/src/platform.ts b/packages/client/web/src/platform.ts index dc6b9e58ed..e7997cf728 100644 --- a/packages/client/web/src/platform.ts +++ b/packages/client/web/src/platform.ts @@ -6,7 +6,7 @@ /** The module specifiers the shell shares into the frozen module table. */ export const PLATFORM_MODULES = [ - 'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'cordis', + 'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', '@deepseek-ai/cordis', '@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client-web-react', '@deepseek-ai/dsh-client-ui-primitives', diff --git a/packages/client/web/src/seed.ts b/packages/client/web/src/seed.ts index fd5360f0f7..868b0fa058 100644 --- a/packages/client/web/src/seed.ts +++ b/packages/client/web/src/seed.ts @@ -10,7 +10,7 @@ import * as React from 'react' import * as ReactJsxRuntime from 'react/jsx-runtime' import * as ReactDom from 'react-dom' import * as ReactDomClient from 'react-dom/client' -import * as Cordis from 'cordis' +import * as Cordis from '@deepseek-ai/cordis' import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots' import * as WebReact from '@deepseek-ai/dsh-client-web-react' import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' @@ -30,7 +30,7 @@ export function getStaticModules(): Record<string, unknown> { 'react/jsx-runtime': ReactJsxRuntime, 'react-dom': ReactDom, 'react-dom/client': ReactDomClient, - 'cordis': Cordis, + '@deepseek-ai/cordis': Cordis, '@deepseek-ai/dsh-client-ui-slots': UiSlots, '@deepseek-ai/dsh-client-web-react': WebReact, '@deepseek-ai/dsh-client-ui-primitives': UiPrimitives, diff --git a/packages/client/web/tests/app-shell.spec.tsx b/packages/client/web/tests/app-shell.spec.tsx index 10acab85d8..445ba787f8 100644 --- a/packages/client/web/tests/app-shell.spec.tsx +++ b/packages/client/web/tests/app-shell.spec.tsx @@ -8,7 +8,7 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { TestSessions, TestWorkspaces } from '@deepseek-ai/dsh-client-test-runtime' import type { Stabilizer } from '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/web/tests/app.spec.tsx b/packages/client/web/tests/app.spec.tsx index b8f5cb2fde..70bdf38611 100644 --- a/packages/client/web/tests/app.spec.tsx +++ b/packages/client/web/tests/app.spec.tsx @@ -6,7 +6,7 @@ */ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, render } from '@testing-library/react' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { buildRenderApp } from '@deepseek-ai/dsh-client-web/src/app.tsx' diff --git a/packages/code-runtime/code-runtime-worker/package.json b/packages/code-runtime/code-runtime-worker/package.json index cc72bf1b12..565541abc8 100644 --- a/packages/code-runtime/code-runtime-worker/package.json +++ b/packages/code-runtime/code-runtime-worker/package.json @@ -33,16 +33,16 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-code-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts index 8193cb587e..df3c3ff98f 100644 --- a/packages/code-runtime/code-runtime-worker/src/index.ts +++ b/packages/code-runtime/code-runtime-worker/src/index.ts @@ -10,8 +10,8 @@ import { Worker } from 'node:worker_threads' import { stripTypeScriptTypes } from 'node:module' import type { Readable } from 'node:stream' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { CodeRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime' import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' diff --git a/packages/code-runtime/code-runtime-worker/src/invariant.ts b/packages/code-runtime/code-runtime-worker/src/invariant.ts index 3455104441..4569372efb 100644 --- a/packages/code-runtime/code-runtime-worker/src/invariant.ts +++ b/packages/code-runtime/code-runtime-worker/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime-worker' diff --git a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts index 5a09dd69f2..f0903002f1 100644 --- a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +++ b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts @@ -18,7 +18,7 @@ const built = ['lib/index.js', 'lib/worker.cjs'].every(file => existsSync(join(p describe.skipIf(!built)('built lib real load path (plain node)', () => { it('runs a TypeScript program with a binding through lib/index.js and its lib/worker.cjs entry', async () => { const script = ` - const { Context } = await import('cordis') + const { Context } = await import('@deepseek-ai/cordis') const { WorkerCodeRuntime } = await import('@deepseek-ai/dsh-code-runtime-worker') const ctx = new Context() await ctx.plugin(WorkerCodeRuntime, {}) diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index 54f58eb414..d15bd9f8bd 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' import type { Config } from '@deepseek-ai/dsh-code-runtime-worker' import type { CodeBindingFunction, CodeBindingNamespace, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index f6e3a08ce1..053e56b54a 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -26,10 +26,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index 9a7716aea0..c23143f821 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-code-runtime */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { CodeRunRequest, CodeRunResult } from './types.ts' export type { @@ -86,7 +86,7 @@ export const PORTABLE_RESERVED_WORDS: ReadonlySet<string> = new Set([ 'global', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'match', 'type', '_', ]) -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { codeRuntime: CodeRuntime } diff --git a/packages/code-runtime/code-runtime/src/invariant.ts b/packages/code-runtime/code-runtime/src/invariant.ts index 9c4019699b..5f234691e0 100644 --- a/packages/code-runtime/code-runtime/src/invariant.ts +++ b/packages/code-runtime/code-runtime/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime' diff --git a/packages/code-runtime/code-runtime/tests/service.spec.ts b/packages/code-runtime/code-runtime/tests/service.spec.ts index 7ea3a30b31..44e356329a 100644 --- a/packages/code-runtime/code-runtime/tests/service.spec.ts +++ b/packages/code-runtime/code-runtime/tests/service.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' diff --git a/packages/compact/command-compact/package.json b/packages/compact/command-compact/package.json index 324051b188..ec043adca7 100644 --- a/packages/compact/command-compact/package.json +++ b/packages/compact/command-compact/package.json @@ -28,17 +28,17 @@ "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-compact": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/compact/command-compact/src/index.ts b/packages/compact/command-compact/src/index.ts index b3f80d87fd..01639a780a 100644 --- a/packages/compact/command-compact/src/index.ts +++ b/packages/compact/command-compact/src/index.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-command-compact */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { ManualCompactionError } from '@deepseek-ai/dsh-compact' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' diff --git a/packages/compact/command-compact/src/invariant.ts b/packages/compact/command-compact/src/invariant.ts index 09b3c04d8d..903c9d4375 100644 --- a/packages/compact/command-compact/src/invariant.ts +++ b/packages/compact/command-compact/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-command-compact' diff --git a/packages/compact/command-compact/tests/command-compact.spec.ts b/packages/compact/command-compact/tests/command-compact.spec.ts index 0c419d6799..ba09e5c42a 100644 --- a/packages/compact/command-compact/tests/command-compact.spec.ts +++ b/packages/compact/command-compact/tests/command-compact.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import type { Agent } from '@deepseek-ai/dsh-agent' import CommandService, { type CommandResult } from '@deepseek-ai/dsh-commands' import { diff --git a/packages/compact/command-compact/tests/loader-composition.spec.ts b/packages/compact/command-compact/tests/loader-composition.spec.ts index 5f7fd0b346..10033d27af 100644 --- a/packages/compact/command-compact/tests/loader-composition.spec.ts +++ b/packages/compact/command-compact/tests/loader-composition.spec.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import type { Agent } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import { diff --git a/packages/compact/compact-basic/README.i18n.yaml b/packages/compact/compact-basic/README.i18n.yaml index 75e9e7be19..146170b779 100644 --- a/packages/compact/compact-basic/README.i18n.yaml +++ b/packages/compact/compact-basic/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compact/compact-basic/README.md -README.md: 97c71cafe1eba7dd29c903535b378439586e807e -README.zh.md: 55a0080fe3c08e5559d737d46f57e23645a52cad +README.md: 4812f858b4773d8cc5ea6a1543430c08f858374f +README.zh.md: 189c5cad5e98533d2dad217404c8c2bcaf41b94b diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 97c71cafe1..4812f858b4 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -49,7 +49,7 @@ An adapter may return no capacity for a valid dynamic route, and resolved capaci `BasicCompactService` requires `ctx.llm`, `ctx.tokenMeter`, and `ctx.sessions`. The composition below receives `ctx.llm` from its host and installs the other two services: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import SessionStore from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' diff --git a/packages/compact/compact-basic/README.zh.md b/packages/compact/compact-basic/README.zh.md index 55a0080fe3..189c5cad5e 100644 --- a/packages/compact/compact-basic/README.zh.md +++ b/packages/compact/compact-basic/README.zh.md @@ -49,7 +49,7 @@ `BasicCompactService` 需要 `ctx.llm`、`ctx.tokenMeter` 和 `ctx.sessions`。以下组合从其宿主接收 `ctx.llm`,并安装另外两项服务: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import SessionStore from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index 3fffee14a7..f407d15188 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -33,7 +33,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-token-meter": "^0.0.1", "@deepseek-ai/dsh-compact-tool-result-prune": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-compact-tool-result-prune": { @@ -41,11 +41,11 @@ } }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -58,6 +58,6 @@ "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index d3c710bef3..773f31d976 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-compact-basic */ -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { CompactService, ManualCompactionError } from '@deepseek-ai/dsh-compact' import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter' diff --git a/packages/compact/compact-basic/src/invariant.ts b/packages/compact/compact-basic/src/invariant.ts index 172790d233..4818d6b5a6 100644 --- a/packages/compact/compact-basic/src/invariant.ts +++ b/packages/compact/compact-basic/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-compact-basic' diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index 681919905e..99e164ce12 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-compact-basic/summarizer */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { contentHasImage, createUserMessage, BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, GenerateOptions, Message, TokenUsage, ToolSchema, diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index b3e088c82a..4d1973326a 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 471207aa97..536ef9d543 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, resolveRetryPolicy , createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index 162c44efd4..2284d54cf9 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' diff --git a/packages/compact/compact-basic/tests/manual-compact.spec.ts b/packages/compact/compact-basic/tests/manual-compact.spec.ts index 0599d3f364..16cf680e1d 100644 --- a/packages/compact/compact-basic/tests/manual-compact.spec.ts +++ b/packages/compact/compact-basic/tests/manual-compact.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/compact/compact-tool-result-prune/README.i18n.yaml b/packages/compact/compact-tool-result-prune/README.i18n.yaml index 78eb863c26..ebe54ccf58 100644 --- a/packages/compact/compact-tool-result-prune/README.i18n.yaml +++ b/packages/compact/compact-tool-result-prune/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compact/compact-tool-result-prune/README.md -README.md: edeba52b189b3cee5530faf7efc04043a326917f -README.zh.md: abc19afa784ec1121430b57a9d90d22d12b89810 +README.md: 8ebc422f37db1f86adedcb207f03eefc75b8d242 +README.zh.md: 8f8920bbc2b6014f98a3d82a4501ae33e367f39c diff --git a/packages/compact/compact-tool-result-prune/README.md b/packages/compact/compact-tool-result-prune/README.md index edeba52b18..8ebc422f37 100644 --- a/packages/compact/compact-tool-result-prune/README.md +++ b/packages/compact/compact-tool-result-prune/README.md @@ -31,7 +31,7 @@ All values are integers; the threshold is positive and head/tail are non-negativ ## Usage ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' export function apply(ctx: Context): void { diff --git a/packages/compact/compact-tool-result-prune/README.zh.md b/packages/compact/compact-tool-result-prune/README.zh.md index abc19afa78..8f8920bbc2 100644 --- a/packages/compact/compact-tool-result-prune/README.zh.md +++ b/packages/compact/compact-tool-result-prune/README.zh.md @@ -31,7 +31,7 @@ ## 用法 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' export function apply(ctx: Context): void { diff --git a/packages/compact/compact-tool-result-prune/package.json b/packages/compact/compact-tool-result-prune/package.json index bc38eccdb0..5a30f7fc97 100644 --- a/packages/compact/compact-tool-result-prune/package.json +++ b/packages/compact/compact-tool-result-prune/package.json @@ -30,19 +30,19 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-token-meter": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/compact/compact-tool-result-prune/src/index.ts b/packages/compact/compact-tool-result-prune/src/index.ts index 48e3ee308b..89e27108e9 100644 --- a/packages/compact/compact-tool-result-prune/src/index.ts +++ b/packages/compact/compact-tool-result-prune/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-compact-tool-result-prune */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { freezeMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, ToolResultMessage } from '@deepseek-ai/dsh-session' @@ -29,7 +29,7 @@ export type { ToolResultPruneConfig, } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { toolResultPrune: ToolResultPruneService } diff --git a/packages/compact/compact-tool-result-prune/src/invariant.ts b/packages/compact/compact-tool-result-prune/src/invariant.ts index 8c2b0a1133..199a4c9073 100644 --- a/packages/compact/compact-tool-result-prune/src/invariant.ts +++ b/packages/compact/compact-tool-result-prune/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-compact-tool-result-prune' diff --git a/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts index fbc4b840c9..9db0f0e976 100644 --- a/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' diff --git a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts index 69a28eb123..672abf1286 100644 --- a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SessionStore, { diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index 860145e542..2a4c3c9518 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -39,7 +39,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -47,6 +47,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 6fb9706c04..d3be6f5750 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -7,7 +7,7 @@ * @module @deepseek-ai/dsh-compact */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { Session } from '@deepseek-ai/dsh-session' import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CompactionResult } from './types.ts' @@ -78,7 +78,7 @@ export interface ManualCompactAgentContext extends CompactAgentContext { runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { compact: CompactService } diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts index adac557db5..1942bc8510 100644 --- a/packages/compact/compact/src/invariant.ts +++ b/packages/compact/compact/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned compaction log-stream invariants. @module @deepseek-ai/dsh-compact/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index 74e2bd0270..c9af4ed2e0 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CompactionId, CompactService, diff --git a/packages/compact/compact/tests/invariant.spec.ts b/packages/compact/compact/tests/invariant.spec.ts index 1637c16179..e49fe8ec3e 100644 --- a/packages/compact/compact/tests/invariant.spec.ts +++ b/packages/compact/compact/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact' diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 48111ca09b..259c7a9a63 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -25,7 +25,7 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", @@ -35,7 +35,7 @@ "@deepseek-ai/dsh-retention": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-query": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -45,6 +45,6 @@ "@deepseek-ai/dsh-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 01a6965027..792f0abd20 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-session-reference */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm' @@ -50,7 +50,7 @@ user explicitly repeats them. ` const PROMPT_SUFFIX = '\n</referenced-sessions>' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sessionReferences: SessionReferenceService } diff --git a/packages/context/session-reference/src/invariant.ts b/packages/context/session-reference/src/invariant.ts index c8a5b0b5c3..9a277e7614 100644 --- a/packages/context/session-reference/src/invariant.ts +++ b/packages/context/session-reference/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-reference' diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index ce964af16a..9f9dee8bb8 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact' import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 8e8421fd01..d2bf0a1dbe 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -25,13 +25,13 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -43,6 +43,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 5e95beb2b2..977d73cd35 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-time-context */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index ffd3fd22a8..8a1a888e4c 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned durable clock-context invariants. @module @deepseek-ai/dsh-time-context/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index b5f0385b3f..02a04eb863 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as TimeInvariant from '@deepseek-ai/dsh-time-context/invariant' diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index a0ffb9e619..5e50b38136 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { createUserMessage, CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 524036a667..99959c18f3 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -25,14 +25,14 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index c489ec0970..8eac866637 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -18,8 +18,8 @@ * @module @deepseek-ai/dsh-tmux-context */ -import type { Context, LoggerService } from 'cordis' -import z from 'schemastery' +import type { Context, LoggerService } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import type { BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/context/tmux-context/src/invariant.ts b/packages/context/tmux-context/src/invariant.ts index 181f1a2289..901f7c4043 100644 --- a/packages/context/tmux-context/src/invariant.ts +++ b/packages/context/tmux-context/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tmux-context' diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 12bc37b9ce..0b28486019 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/context/workspace-context/package.json b/packages/context/workspace-context/package.json index 6548fb49a8..4972796a44 100644 --- a/packages/context/workspace-context/package.json +++ b/packages/context/workspace-context/package.json @@ -32,13 +32,13 @@ "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", @@ -51,6 +51,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" } } diff --git a/packages/context/workspace-context/src/config.ts b/packages/context/workspace-context/src/config.ts index c1a1fad1e6..cc3d9d7bbf 100644 --- a/packages/context/workspace-context/src/config.ts +++ b/packages/context/workspace-context/src/config.ts @@ -5,7 +5,7 @@ */ import { relative } from 'node:path' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import { resolveDshHome } from '@deepseek-ai/dsh-paths' const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index fee08f2c08..22611d2658 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-workspace-context */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { isDeepStrictEqual } from 'node:util' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/context/workspace-context/src/invariant.ts b/packages/context/workspace-context/src/invariant.ts index f6c99ea10e..a3860cb122 100644 --- a/packages/context/workspace-context/src/invariant.ts +++ b/packages/context/workspace-context/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-workspace-context' diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index c1151428e2..0ed6d812ac 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -3,7 +3,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index a40d3aec9a..0f177a1d49 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -2,8 +2,8 @@ import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'nod import { dirname, join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import LlmService, { createUserMessage, CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index 0035b0b617..e57709585b 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -25,20 +25,20 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/agent-default-model/src/index.ts b/packages/core/agent-default-model/src/index.ts index 4d09b86eb3..5d9e92fc51 100644 --- a/packages/core/agent-default-model/src/index.ts +++ b/packages/core/agent-default-model/src/index.ts @@ -4,13 +4,13 @@ * @module @deepseek-ai/dsh-agent-default-model */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { ModelSelection } from '@deepseek-ai/dsh-agent' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Default model selection for Agents created without an explicit model. */ agentDefaultModel: AgentDefaultModelService diff --git a/packages/core/agent-default-model/src/invariant.ts b/packages/core/agent-default-model/src/invariant.ts index 8366018661..48253ae159 100644 --- a/packages/core/agent-default-model/src/invariant.ts +++ b/packages/core/agent-default-model/src/invariant.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-agent-default-model/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-default-model' diff --git a/packages/core/agent-default-model/tests/agent-default-model.spec.ts b/packages/core/agent-default-model/tests/agent-default-model.spec.ts index ef479b4a96..61324d7d71 100644 --- a/packages/core/agent-default-model/tests/agent-default-model.spec.ts +++ b/packages/core/agent-default-model/tests/agent-default-model.spec.ts @@ -1,7 +1,7 @@ /** Default Agent model settings layered over a real settings provider. */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentDefaultModelService, { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '../src/index.ts' import { Settings } from '@deepseek-ai/dsh-settings' import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 68c30d9e55..fe37f8ca1c 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -32,10 +32,10 @@ "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -47,6 +47,6 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 6ef965e59e..a2e854f6d4 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -31,7 +31,7 @@ import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, Us import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { RuntimeContextProjection } from './runtime-context.ts' import { executeToolCalls } from './tool-calls.ts' diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index e7c840e296..e492a21073 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -5,9 +5,9 @@ * @module @deepseek-ai/dsh-agent-loop */ -import { Context, FiberState, Service } from 'cordis' +import { Context, FiberState, Service } from '@deepseek-ai/cordis' import { randomUUID } from 'node:crypto' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import { emitAgentEvent } from '@deepseek-ai/dsh-agent' import type { Agent, @@ -156,7 +156,7 @@ interface PreparedAgent { dispose(): Promise<void> } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { agentLoop: AgentLoop /** diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts index d87655d1fc..80fdfba8f9 100644 --- a/packages/core/agent-loop/src/invariant.ts +++ b/packages/core/agent-loop/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-agent-loop/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { isAgentLoopRequest, type GenerateOptions } from '@deepseek-ai/dsh-llm' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { foldRequestHeader } from '@deepseek-ai/dsh-session' diff --git a/packages/core/agent-loop/src/runtime-context.ts b/packages/core/agent-loop/src/runtime-context.ts index 8cf4a41403..63b353ebae 100644 --- a/packages/core/agent-loop/src/runtime-context.ts +++ b/packages/core/agent-loop/src/runtime-context.ts @@ -7,7 +7,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextSnapshotSection } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' const SOURCE = '@deepseek-ai/dsh-system-prompt' const CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.' diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index 693ff7bd91..cb34f3dc86 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -11,7 +11,7 @@ * @module dsh-agent-loop/tool-calls */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { assertNever, createToolResultMessage, type ToolCallBlock } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools' diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 5af6e70fe0..8195ea1df9 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context, type Fiber } from 'cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmService, { createUserMessage, CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 7ac8a0dd64..e02925ca75 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmService from '@deepseek-ai/dsh-llm' diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 6fb77d141c..9e53d8067e 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -8,7 +8,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 74608e5f1a..40b226f165 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 7dfbaddc41..50f6257f45 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason, type UserMessage } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 617c305071..2ebe1c0884 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId, LlmError, StreamChunk, errorChain } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 646c6d41fa..01cf57237b 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts index d77ad3a7d6..4295eac226 100644 --- a/packages/core/agent-loop/tests/invariant.spec.ts +++ b/packages/core/agent-loop/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import InvariantService from '@deepseek-ai/dsh-invariants' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index c42331de58..d87fb56486 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 0add31bd1f..7fcba03ebf 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -10,7 +10,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 0c7c65e483..0e0da3cb81 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index d143bd79ae..eae579a8cb 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmService, { createUserMessage, LlmError } from '@deepseek-ai/dsh-llm' diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 62a7ae5e71..dc69e1b835 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 964ef82aa6..e32db7c846 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/core/agent-loop/tests/runtime-context.spec.ts b/packages/core/agent-loop/tests/runtime-context.spec.ts index 463515a61b..9cf76e79ba 100644 --- a/packages/core/agent-loop/tests/runtime-context.spec.ts +++ b/packages/core/agent-loop/tests/runtime-context.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { RuntimeContextProjection } from '../src/runtime-context.ts' diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 3f3e0a43d8..898604ff1b 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context, symbols, type EffectMeta, type Fiber } from 'cordis' +import { Context, symbols, type EffectMeta, type Fiber } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 89b2e300bd..972c321138 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 9fa321697a..195b1ad76a 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -8,7 +8,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent-tool-mode/package.json b/packages/core/agent-tool-mode/package.json index 236c9e5891..8427822784 100644 --- a/packages/core/agent-tool-mode/package.json +++ b/packages/core/agent-tool-mode/package.json @@ -25,12 +25,12 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -40,6 +40,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/agent-tool-mode/src/index.ts b/packages/core/agent-tool-mode/src/index.ts index d2f1e8fd49..7e4ee9e0c0 100644 --- a/packages/core/agent-tool-mode/src/index.ts +++ b/packages/core/agent-tool-mode/src/index.ts @@ -16,8 +16,8 @@ * @module @deepseek-ai/dsh-agent-tool-mode */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { ToolPresentationMode } from '@deepseek-ai/dsh-tools' // Type-only: brings the `ctx.tools` Context merge into this program. import type {} from '@deepseek-ai/dsh-tools' diff --git a/packages/core/agent-tool-mode/src/invariant.ts b/packages/core/agent-tool-mode/src/invariant.ts index bd576cb943..a7fbd8b923 100644 --- a/packages/core/agent-tool-mode/src/invariant.ts +++ b/packages/core/agent-tool-mode/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-tool-mode' diff --git a/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts b/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts index ba9b9972ff..b626025dfd 100644 --- a/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts +++ b/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createScope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 9f64d33e75..5669858fd9 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -36,7 +36,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-type-meta": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -46,6 +46,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index 7eff09b01d..f95582851d 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-agent/dispatch */ -import type { Context, Events } from 'cordis' +import type { Context, Events } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 0f2fcabf3a..dffc1f01b3 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-agent */ -import { Context, FiberState, getTraceable, Service, symbols } from 'cordis' -import type { Fiber } from 'cordis' +import { Context, FiberState, getTraceable, Service, symbols } from '@deepseek-ai/cordis' +import type { Fiber } from '@deepseek-ai/cordis' import { AsyncLocalStorage } from 'node:async_hooks' import { isPromise } from 'node:util/types' import { scopeTarget } from '@deepseek-ai/dsh-scope' @@ -32,7 +32,7 @@ declare module '@deepseek-ai/dsh-type-meta' { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { agents: AgentRegistry /** diff --git a/packages/core/agent/src/invariant.ts b/packages/core/agent/src/invariant.ts index a561e862cb..fc3ebb2599 100644 --- a/packages/core/agent/src/invariant.ts +++ b/packages/core/agent/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned agent lifecycle invariants. @module @deepseek-ai/dsh-agent/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' diff --git a/packages/core/agent/src/model-selection.ts b/packages/core/agent/src/model-selection.ts index a49e2f5979..2cb7e4468f 100644 --- a/packages/core/agent/src/model-selection.ts +++ b/packages/core/agent/src/model-selection.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-agent/model-selection */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { LlmCallConfig, ReasoningEffortId } from '@deepseek-ai/dsh-llm' /** Complete provider, model, and optional reasoning effort selected for one live Agent. */ diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 3698c05018..7d713f8c77 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-agent */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' @@ -143,7 +143,7 @@ export interface Agent { inject(message: UserMessage): void } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { // ---- lifecycle (emit) ---- /** diff --git a/packages/core/agent/tests/agent-initiator.spec.ts b/packages/core/agent/tests/agent-initiator.spec.ts index 7e0b70d13c..c86e0edfcf 100644 --- a/packages/core/agent/tests/agent-initiator.spec.ts +++ b/packages/core/agent/tests/agent-initiator.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { runInNewContext } from 'node:vm' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 643a3a49a6..d5c345593c 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it } from 'vitest' -import { Context, Service, symbols } from 'cordis' +import { Context, Service, symbols } from '@deepseek-ai/cordis' import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm' import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' import AgentRegistry, { diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts index 458a10714d..26bc486b2e 100644 --- a/packages/core/agent/tests/invariant.spec.ts +++ b/packages/core/agent/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import { scopeTarget } from '@deepseek-ai/dsh-scope' diff --git a/packages/core/agent/tests/model-selection.spec.ts b/packages/core/agent/tests/model-selection.spec.ts index 2d7a1a7336..3e61060cd6 100644 --- a/packages/core/agent/tests/model-selection.spec.ts +++ b/packages/core/agent/tests/model-selection.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { agentEvents, diff --git a/packages/core/agent/tests/verify-export-jsdoc.spec.ts b/packages/core/agent/tests/verify-export-jsdoc.spec.ts index cc361a956e..cffa4e8564 100644 --- a/packages/core/agent/tests/verify-export-jsdoc.spec.ts +++ b/packages/core/agent/tests/verify-export-jsdoc.spec.ts @@ -154,7 +154,7 @@ describe('verify-export-jsdoc type-level exports', () => { it('skips `declare module` augmentation bodies (the cordis gate owns them)', () => { expect(collectExportJsdocViolations(make( - "declare module 'cordis' {\n interface Events {\n 'fix/x'(): void\n }\n}\nexport {}\n", + "declare module '@deepseek-ai/cordis' {\n interface Events {\n 'fix/x'(): void\n }\n}\nexport {}\n", ))).toEqual([]) }) }) diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index a752ee4c77..792a6716e6 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -26,10 +26,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index b5f58dbdf0..84df79510e 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-scope */ -import type { Context, Fiber } from 'cordis' -import { Context as CordisContext } from 'cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' +import { Context as CordisContext } from '@deepseek-ai/cordis' export { AnonymousEntries, NamedEntries, ScopedLayers } from './store.ts' export type { ScopeLayer } from './store.ts' diff --git a/packages/core/scope/src/invariant.ts b/packages/core/scope/src/invariant.ts index a5bd59f263..b478b9417d 100644 --- a/packages/core/scope/src/invariant.ts +++ b/packages/core/scope/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned scoped-dispatch invariants. @module @deepseek-ai/dsh-scope/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope' import { scopedSubjectResolverFor } from './scoped-events.generated.ts' diff --git a/packages/core/scope/src/store.ts b/packages/core/scope/src/store.ts index a9e1468ccd..3b40693c82 100644 --- a/packages/core/scope/src/store.ts +++ b/packages/core/scope/src/store.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-scope */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { scopeChainOf, scopeOf } from './index.ts' import type { ScopeKey } from './index.ts' diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index 8744bb9aa7..5e8ba13a3e 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -1,7 +1,7 @@ import { freezeMessage, MessageId } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import type { Events } from 'cordis' +import { Context } from '@deepseek-ai/cordis' +import type { Events } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeTarget } from '@deepseek-ai/dsh-scope' import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant' diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 7007624d53..bb0361fbf2 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, expectTypeOf, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { bindScopeParent, carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scope, Scoped } from '@deepseek-ai/dsh-scope' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { /** * Test-only event for scope-filtered dispatch. diff --git a/packages/core/scope/tests/store.spec.ts b/packages/core/scope/tests/store.spec.ts index 622dbeb541..025f0f7181 100644 --- a/packages/core/scope/tests/store.spec.ts +++ b/packages/core/scope/tests/store.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { AnonymousEntries, createScope, diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 04aa221573..ada5c170d1 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -39,7 +39,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-type-meta": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -48,6 +48,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index f27b2f4622..dd1f7b9175 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-session */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { isAbsolute } from 'node:path' import { deepFreeze } from '@deepseek-ai/dsh-llm' import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' @@ -54,7 +54,7 @@ export function findLastMessageTurnEnd( return latest } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sessions: SessionStore } diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index 77d2b22ac2..da7cd55964 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-session/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { assertNever } from '@deepseek-ai/dsh-llm' import type { CallId } from '@deepseek-ai/dsh-llm' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 58985cfb34..35b0cf3a54 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index cc5e21f57a..5d6fff4cef 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope' import { createUserMessage, CallId, createMessage, createToolResultMessage, freezeMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TOOL_NOT_STARTED } from '@deepseek-ai/dsh-session' diff --git a/packages/core/session/tests/scoped.spec.ts b/packages/core/session/tests/scoped.spec.ts index 441d2d8029..7d75302248 100644 --- a/packages/core/session/tests/scoped.spec.ts +++ b/packages/core/session/tests/scoped.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index b0302b9d4d..779a24e748 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import SessionStore, { adoptSessionEvent, diff --git a/packages/core/session/tests/typert.spec.ts b/packages/core/session/tests/typert.spec.ts index e1e2b32d68..2ea8e4572e 100644 --- a/packages/core/session/tests/typert.spec.ts +++ b/packages/core/session/tests/typert.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index d601365df7..35a270cf9d 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -28,15 +28,15 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 23b5936e08..d4f5c52db0 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -4,13 +4,13 @@ * @module @deepseek-ai/dsh-system-prompt */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope' import type { ContextSnapshotSection, ToolSchema } from '@deepseek-ai/dsh-llm' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { systemPrompt: SystemPrompt } diff --git a/packages/core/system-prompt/src/invariant.ts b/packages/core/system-prompt/src/invariant.ts index 04dc65e7ad..bbe587516f 100644 --- a/packages/core/system-prompt/src/invariant.ts +++ b/packages/core/system-prompt/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned prompt-assembly invariants. @module @deepseek-ai/dsh-system-prompt/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { PromptAssembly } from './index.ts' diff --git a/packages/core/system-prompt/tests/invariant.spec.ts b/packages/core/system-prompt/tests/invariant.spec.ts index ace65d2bc9..74c3648494 100644 --- a/packages/core/system-prompt/tests/invariant.spec.ts +++ b/packages/core/system-prompt/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import * as SystemPromptInvariant from '@deepseek-ai/dsh-system-prompt/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/core/system-prompt/tests/scoped.spec.ts b/packages/core/system-prompt/tests/scoped.spec.ts index 704a3e769c..1498966cc6 100644 --- a/packages/core/system-prompt/tests/scoped.spec.ts +++ b/packages/core/system-prompt/tests/scoped.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope' import SystemPrompt, { TOOL_ORDER_REST, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 834c17c341..96592565ce 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt, { AssembleContext, PromptAssembly, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt' /** diff --git a/packages/core/system-prompt/tests/tool-order.spec.ts b/packages/core/system-prompt/tests/tool-order.spec.ts index 16eff6e354..6085276900 100644 --- a/packages/core/system-prompt/tests/tool-order.spec.ts +++ b/packages/core/system-prompt/tests/tool-order.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt, { PromptAssembly, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { ToolSchema } from '@deepseek-ai/dsh-llm' diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index e3a9b36a95..317f583994 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: 21851ca887147364c76612bae2e6a00ebdccec39 -README.zh.md: aec3b434e52f473001505bbea5212d5e247eb46f +README.md: cc7b323ae1de917e93e243e97bd5cf5937ecdca4 +README.zh.md: 8d4ae42596483f77aa82b23a0b41168465e2b165 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 21851ca887..cc7b323ae1 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -66,7 +66,7 @@ First-party plugin authors can use the `defineTool()` helper (exported from this ```ts import { readFile } from 'node:fs/promises' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' declare const ctx: Context diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index aec3b434e5..8d4ae42596 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -66,7 +66,7 @@ tools: ```ts import { readFile } from 'node:fs/promises' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' declare const ctx: Context diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 0fcc66b497..7070431132 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -42,10 +42,10 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -56,6 +56,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 86c69d9307..4073b6c705 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-tools */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' @@ -120,7 +120,7 @@ export type { WebSource, } from './presentation.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { tools: ToolRegistry } diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index a0d9487857..5489f61d80 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned tool-pipeline invariants. @module @deepseek-ai/dsh-tools/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ToolExecution, ToolExecutionResult } from './index.ts' diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 1794e7e36e..c1d49808d8 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' diff --git a/packages/core/tools/tests/execution-mode.spec.ts b/packages/core/tools/tests/execution-mode.spec.ts index 54c4687c60..cbac6fcecc 100644 --- a/packages/core/tools/tests/execution-mode.spec.ts +++ b/packages/core/tools/tests/execution-mode.spec.ts @@ -1,7 +1,7 @@ /** Covers fail-closed per-call classification and model-schema isolation. */ import { describe, expect, expectTypeOf, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { diff --git a/packages/core/tools/tests/execution-signal-types.spec.ts b/packages/core/tools/tests/execution-signal-types.spec.ts index dd878d648e..0ac4ac85c0 100644 --- a/packages/core/tools/tests/execution-signal-types.spec.ts +++ b/packages/core/tools/tests/execution-signal-types.spec.ts @@ -1,5 +1,5 @@ import { describe, expectTypeOf, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index 8a60c41331..28badfbc58 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { CallId } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 8f7be45b19..6f03e0aef4 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' -import { Context } from 'cordis' -import type { Events } from 'cordis' +import { Context } from '@deepseek-ai/cordis' +import type { Events } from '@deepseek-ai/cordis' import { bindScopeParent, createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index cfb1f6db05..217875898d 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId, HarnessError, type ContentBlock } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index f4fbb3a9fe..483fc3d5d0 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -30,11 +30,11 @@ "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "chokidar": "^4.0.3", - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "^3.18.0", "yaml": "^2.9.0" }, "devDependencies": { @@ -43,6 +43,6 @@ "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index e7800253f3..c61a7521e9 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -35,8 +35,8 @@ * @module @deepseek-ai/dsh-credentials-local */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { watch as chokidarWatch } from 'chokidar' import { mkdir, readFile, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' diff --git a/packages/credentials/credentials-local/src/invariant.ts b/packages/credentials/credentials-local/src/invariant.ts index 454f2b808e..0ea27d0071 100644 --- a/packages/credentials/credentials-local/src/invariant.ts +++ b/packages/credentials/credentials-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-credentials-local' diff --git a/packages/credentials/credentials-local/tests/drain.spec.ts b/packages/credentials/credentials-local/tests/drain.spec.ts index 9cf4e600fb..bbd2feec26 100644 --- a/packages/credentials/credentials-local/tests/drain.spec.ts +++ b/packages/credentials/credentials-local/tests/drain.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index e9f65356bd..d66aa83047 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' diff --git a/packages/credentials/credentials-local/tests/review-fixes.spec.ts b/packages/credentials/credentials-local/tests/review-fixes.spec.ts index b7839cf538..6a9aad7ea4 100644 --- a/packages/credentials/credentials-local/tests/review-fixes.spec.ts +++ b/packages/credentials/credentials-local/tests/review-fixes.spec.ts @@ -3,7 +3,7 @@ // broken observer never fails a committed write), and the YAML document // editor's isolation between entries. import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts index e49f1421b4..11307cde0d 100644 --- a/packages/credentials/credentials-local/tests/watcher.spec.ts +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/credentials/credentials/README.i18n.yaml b/packages/credentials/credentials/README.i18n.yaml index 053af47617..756ba1ba04 100644 --- a/packages/credentials/credentials/README.i18n.yaml +++ b/packages/credentials/credentials/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/credentials/README.md -README.md: fc4fb16991a3396f106bed65b468d0ae538bbab8 -README.zh.md: 6007618e5dc917ebc38fd322b34427cb5281e70c +README.md: 5cfeb8e6656fabd638ea2126e56bc66391f0ca01 +README.zh.md: 28a029f0e4909fb14a1208458dcd1d22afe4abb9 diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md index fc4fb16991..5cfeb8e665 100644 --- a/packages/credentials/credentials/README.md +++ b/packages/credentials/credentials/README.md @@ -13,7 +13,7 @@ Credential Service Definition (`ctx.credentials`). One doctrine, three consequen ## Surface ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { credentialRef } from '@deepseek-ai/dsh-credentials' declare const ctx: Context diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md index 6007618e5d..28a029f0e4 100644 --- a/packages/credentials/credentials/README.zh.md +++ b/packages/credentials/credentials/README.zh.md @@ -13,7 +13,7 @@ ## 接口 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { credentialRef } from '@deepseek-ai/dsh-credentials' declare const ctx: Context diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index 42dd183fc4..2b356c197c 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -27,11 +27,11 @@ "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts index 5f1efb010c..b4fb1569f1 100644 --- a/packages/credentials/credentials/src/index.ts +++ b/packages/credentials/credentials/src/index.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-credentials */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { Branded } from '@deepseek-ai/dsh-brand' /** Nominal reference to one credential: a POSIX-style environment-variable name. */ @@ -46,7 +46,7 @@ export interface CredentialInfo { writable: boolean } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { credentials: Credentials } diff --git a/packages/credentials/credentials/src/invariant.ts b/packages/credentials/credentials/src/invariant.ts index 23c2dda45b..790388ffae 100644 --- a/packages/credentials/credentials/src/invariant.ts +++ b/packages/credentials/credentials/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-credentials/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-credentials' diff --git a/packages/credentials/credentials/tests/credentials.spec.ts b/packages/credentials/credentials/tests/credentials.spec.ts index 9b4cf7b1e8..a4676a2ea8 100644 --- a/packages/credentials/credentials/tests/credentials.spec.ts +++ b/packages/credentials/credentials/tests/credentials.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { credentialRef } from '../src/index.ts' import type { CredentialRef } from '../src/index.ts' import { MemoryCredentials } from './memory.ts' diff --git a/packages/credentials/credentials/tests/invariant.spec.ts b/packages/credentials/credentials/tests/invariant.spec.ts index dccde4843f..f1af9d28b8 100644 --- a/packages/credentials/credentials/tests/invariant.spec.ts +++ b/packages/credentials/credentials/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import { credentialRef } from '../src/index.ts' import * as CredentialsInvariant from '../src/invariant.ts' diff --git a/packages/credentials/credentials/tests/memory.ts b/packages/credentials/credentials/tests/memory.ts index dc1ed77a06..5b8ab32262 100644 --- a/packages/credentials/credentials/tests/memory.ts +++ b/packages/credentials/credentials/tests/memory.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { Credentials } from '../src/index.ts' import type { CredentialInfo, CredentialRef, ResolvedCredential } from '../src/index.ts' diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index 5abacb278a..edb7077d63 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -26,16 +26,16 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "e2b": "2.29.1", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/e2b/e2b/src/index.ts b/packages/e2b/e2b/src/index.ts index 906941d421..b428f45cf3 100644 --- a/packages/e2b/e2b/src/index.ts +++ b/packages/e2b/e2b/src/index.ts @@ -6,8 +6,8 @@ import { randomUUID } from 'node:crypto' import { posix } from 'node:path' -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { FileType, Sandbox, SandboxNotFoundError } from 'e2b' export { @@ -60,7 +60,7 @@ interface SchemaResolvedConfig extends Config { timeoutMs: number } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { e2b: E2BSandboxService } diff --git a/packages/e2b/e2b/src/invariant.ts b/packages/e2b/e2b/src/invariant.ts index 891cabb2db..63bf988e50 100644 --- a/packages/e2b/e2b/src/invariant.ts +++ b/packages/e2b/e2b/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-e2b' diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index 6da102a827..76f54ba7fd 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -1,7 +1,7 @@ import { access } from 'node:fs/promises' import { join, posix } from 'node:path' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/e2b/e2b/tests/e2b.spec.ts b/packages/e2b/e2b/tests/e2b.spec.ts index b108bc68b0..9b9e4b0b9d 100644 --- a/packages/e2b/e2b/tests/e2b.spec.ts +++ b/packages/e2b/e2b/tests/e2b.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Mock } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Sandbox as SandboxType } from 'e2b' import E2BSandboxService, { e2bControlEnvs, diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index 02925ee7c4..7b7faf9c03 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -28,12 +28,12 @@ "@deepseek-ai/dsh-e2b": "^0.0.1", "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-e2b": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/e2b/fs-e2b/src/invariant.ts b/packages/e2b/fs-e2b/src/invariant.ts index 9f14bb37a6..891b157aec 100644 --- a/packages/e2b/fs-e2b/src/invariant.ts +++ b/packages/e2b/fs-e2b/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-e2b' diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index 6ee3df6543..5eed6007c4 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer' import { dirname, posix } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CommandExitError, FileNotFoundError, diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index 007b88f1c9..182c41b8f5 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -29,16 +29,16 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-e2b": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/e2b/subprocess-e2b/src/index.ts b/packages/e2b/subprocess-e2b/src/index.ts index 3c0063c457..bd3978f818 100644 --- a/packages/e2b/subprocess-e2b/src/index.ts +++ b/packages/e2b/subprocess-e2b/src/index.ts @@ -6,8 +6,8 @@ import { randomUUID } from 'node:crypto' import { posix } from 'node:path' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { SubprocessService } from '@deepseek-ai/dsh-subprocess' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { diff --git a/packages/e2b/subprocess-e2b/src/invariant.ts b/packages/e2b/subprocess-e2b/src/invariant.ts index 9f8b8fb739..733310245a 100644 --- a/packages/e2b/subprocess-e2b/src/invariant.ts +++ b/packages/e2b/subprocess-e2b/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess-e2b' diff --git a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts index 153b144ccd..6272320e0c 100644 --- a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts @@ -1,5 +1,5 @@ import { once } from 'node:events' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CommandExitError, FileNotFoundError, diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index a142817870..1e3e296a7f 100644 --- a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer' import { once } from 'node:events' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { CommandExitError, diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index ce4b65466a..0e110ec3d4 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -33,8 +33,8 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@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", @@ -45,12 +45,12 @@ "@deepseek-ai/dsh-session-query-sqlite": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", - "cordis": "^4.0.0-rc.7", - "schemastery": "^3.17.0" + "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/schemastery": "^3.17.0" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", @@ -63,7 +63,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", - "cordis": "^4.0.0-rc.7", - "schemastery": "^3.17.0" + "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/schemastery": "^3.17.0" } } diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 8a900dd3cd..1bd0c44459 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -11,9 +11,9 @@ * @module @deepseek-ai/dsh-acp-demo */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { join } from 'node:path' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import * as acp from '@deepseek-ai/dsh-acp' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' diff --git a/packages/examples/acp-demo/src/invariant.ts b/packages/examples/acp-demo/src/invariant.ts index 95b57b57e1..106ba974d5 100644 --- a/packages/examples/acp-demo/src/invariant.ts +++ b/packages/examples/acp-demo/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp-demo' diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 9c60bee081..af467660da 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -3,8 +3,8 @@ import { randomUUID } from 'node:crypto' import { mkdtemp } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { agentEvents } from '@deepseek-ai/dsh-agent' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Message } from '@deepseek-ai/dsh-llm' diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index 44de45d88f..87c1dc1562 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/agent-spine-demo/README.md -README.md: cf0dc2ecd6e51eb872be75dfe6d80a5338605195 -README.zh.md: e5a8672d494e0c456aa820641e685d00be624445 +README.md: 5957d9a8e9218e18d5d7d0f620b6be811f2c230f +README.zh.md: 78372240764ff3c779ea0805aedd26a00baf1768 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index cf0dc2ecd6..5957d9a8e9 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -11,7 +11,7 @@ Read this package for the whole plugin tree and its composition order. `apply(ctx, config)` mounts each of these as a child of the bundle fiber: ``` -@cordisjs/plugin-timer timer service (writes nothing to stdout) +@deepseek-ai/cordis-plugin-timer timer service (writes nothing to stdout) @deepseek-ai/dsh-llm abstract LLM service + content-block vocabulary @deepseek-ai/dsh-session event-sourced session log + store @deepseek-ai/dsh-session-title log-backed title service + deterministic fallback diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index e5a8672d49..7837224076 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -11,7 +11,7 @@ `apply(ctx, config)` 将以下每个插件挂载为组合包 fiber 的子节点: ``` -@cordisjs/plugin-timer timer service (writes nothing to stdout) +@deepseek-ai/cordis-plugin-timer timer service (writes nothing to stdout) @deepseek-ai/dsh-llm abstract LLM service + content-block vocabulary @deepseek-ai/dsh-session event-sourced session log + store @deepseek-ai/dsh-session-title log-backed title service + deterministic fallback diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index e6c6ce351d..73520f5645 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -25,7 +25,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@cordisjs/plugin-timer": "^1.1.2", + "@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", @@ -48,10 +48,10 @@ "@deepseek-ai/dsh-tool-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-bash-env": "workspace:^", @@ -85,9 +85,9 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/node-addon-landlock-run": "workspace:*", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" } } diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 7d9e99a908..383fc8c212 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -8,9 +8,9 @@ * @module @deepseek-ai/dsh-agent-spine-demo */ -import type { Context } from 'cordis' -import Timer from '@cordisjs/plugin-timer' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Timer from '@deepseek-ai/cordis-plugin-timer' +import z from '@deepseek-ai/schemastery' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import SessionTitleService, { type Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title' diff --git a/packages/examples/agent-spine-demo/src/invariant.ts b/packages/examples/agent-spine-demo/src/invariant.ts index fada985329..9735d8e74d 100644 --- a/packages/examples/agent-spine-demo/src/invariant.ts +++ b/packages/examples/agent-spine-demo/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-spine-demo' diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 8de047a79a..dd059793d5 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -2,8 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { join, sep } from 'node:path' import { tmpdir } from 'node:os' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { renderPrompt, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts b/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts index ae4966fd12..dfd3ff407f 100644 --- a/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts +++ b/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts @@ -43,7 +43,7 @@ export interface Config { describe('gen-config-catalog classification', () => { it('classifies an apply plugin with a config parameter and extracts the paste', () => { const entries = collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' export const inject = ['tools'] ${DOCUMENTED_CONFIG} /** Load. */ @@ -57,8 +57,8 @@ export function apply(ctx: Context, config: Config): void {} it('classifies a default service class, reading its constructor and static inject', () => { const entries = collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' ${DOCUMENTED_CONFIG} /** Fixture service. */ export default class Fix { @@ -110,7 +110,7 @@ export default class Fix { describe('gen-config-catalog config extraction guards', () => { it('hard-errors on a config field with no JSDoc prose', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' export interface Config { knob?: string } @@ -122,7 +122,7 @@ export function apply(ctx: Context, config: Config): void {} it('hard-errors on an undocumented field nested in a type literal', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' /** Fixture config. */ export interface Config { /** Entries. */ @@ -138,7 +138,7 @@ export function apply(ctx: Context, config: Config): void {} it('pastes a package-local type transitively and records external refs', () => { const entries = collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' import type { Mode } from './types.ts' import type { Remote } from '@fix/dep' /** Fixture config. */ @@ -162,7 +162,7 @@ export function apply(ctx: Context, config: Config): void {} it('pastes an enum referenced by the config type', () => { const entries = collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' /** Fixture mode. */ export enum Mode { A = 'a', @@ -185,7 +185,7 @@ export function apply(ctx: Context, config: Config): void {} it('hard-errors on a referenced type name that resolves nowhere', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' /** Fixture config. */ export interface Config { /** The ghost. */ @@ -199,7 +199,7 @@ export function apply(ctx: Context, config: Config): void {} it('hard-errors on a config type imported from another package', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' import type { Config } from '@fix/dep' /** Load. */ export function apply(ctx: Context, config: Config): void {} @@ -209,7 +209,7 @@ export function apply(ctx: Context, config: Config): void {} it('hard-errors when one name resolves to two different declarations across the closure', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' import type { A } from './a.ts' import type { B } from './b.ts' /** Fixture config. */ @@ -231,8 +231,8 @@ export function apply(ctx: Context, config: Config): void {} describe('gen-config-catalog schema cross-check', () => { it('accepts a chained schema whose keys all appear on the config type', () => { const entries = collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' ${DOCUMENTED_CONFIG} export const Config: z<Config> = z.object({ knob: z.string() }).default({}) /** Load. */ @@ -244,8 +244,8 @@ export function apply(ctx: Context, config: Config): void {} it('hard-errors on a schema key the config type does not declare', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' ${DOCUMENTED_CONFIG} export const Config: z<Config> = z.object({ knob: z.string(), hidden: z.number() }) /** Load. */ @@ -256,8 +256,8 @@ export function apply(ctx: Context, config: Config): void {} it('hard-errors on a NESTED schema key the config type does not declare', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' /** Fixture config. */ export interface Config { /** Entries. */ @@ -280,8 +280,8 @@ export function apply(ctx: Context, config: Config): void {} 'src/types.ts': '/** Shared options. */\nexport interface Opts {\n /** Model. */\n model?: string\n}\n', }) writePkg(root, 'group/one', '@fix/one', { - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Opts } from '@fix/dep' /** Fixture config. */ export interface Config { @@ -301,8 +301,8 @@ export function apply(ctx: Context, config: Config): void {} it('resolves nested keys through a Partial<> wrapper', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' /** Caps. */ export interface Caps { /** X. */ @@ -322,8 +322,8 @@ export function apply(ctx: Context, config: Config): void {} it('leaves a nested key under an external (unresolvable) type unreported', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { External } from 'some-external-pkg' /** Fixture config. */ export interface Config { @@ -340,8 +340,8 @@ export function apply(ctx: Context, config: Config): void {} it('folds an intersected workspace schema into the subset check', () => { const root = makeRoot() writePkg(root, 'group/leaf', '@fix/leaf', { - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' /** Leaf config. */ export interface Config { /** Leaf knob. */ @@ -355,8 +355,8 @@ export default class Leaf { `, }) writePkg(root, 'group/bundle', '@fix/bundle', { - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import Leaf from '@fix/leaf' /** Bundle config. */ export interface Config { @@ -375,8 +375,8 @@ export function apply(ctx: Context, config: Config): void {} it('resolves composed nested keys through an indexed-access forwarder', () => { const root = makeRoot() writePkg(root, 'group/leaf', '@fix/leaf', { - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' /** Leaf config. */ export interface Config { /** Agents. */ @@ -393,8 +393,8 @@ export default class Leaf { `, }) writePkg(root, 'group/bundle', '@fix/bundle', { - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import Leaf, { type Config as LeafConfig } from '@fix/leaf' /** Bundle config forwarding the leaf's agents list. */ export interface Config { @@ -412,8 +412,8 @@ export function apply(ctx: Context, config: Config): void {} it('hard-errors when an intersected schema key is missing from the bundle config type', () => { const root = makeRoot() writePkg(root, 'group/leaf', '@fix/leaf', { - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' /** Leaf config. */ export interface Config { /** Leaf knob. */ @@ -427,8 +427,8 @@ export default class Leaf { `, }) writePkg(root, 'group/bundle', '@fix/bundle', { - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import Leaf from '@fix/leaf' /** Bundle config that forgot to declare the forwarded field. */ export interface Config { @@ -448,7 +448,7 @@ describe('gen-config-catalog render', () => { it('renders sections, fences, and the terse classification lists', () => { const root = makeRoot() writePkg(root, 'group/one', '@fix/one', { - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' ${DOCUMENTED_CONFIG} /** Load. */ export function apply(ctx: Context, config: Config): void {} diff --git a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts index 53722aa7e5..8769d91e1b 100644 --- a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts +++ b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promis import { homedir } from 'node:os' import { basename, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index 155660d190..774028c914 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -37,10 +37,10 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/examples/jsonrpc-demo/src/invariant.ts b/packages/examples/jsonrpc-demo/src/invariant.ts index dd093a5418..9eb3eeb53a 100644 --- a/packages/examples/jsonrpc-demo/src/invariant.ts +++ b/packages/examples/jsonrpc-demo/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc-demo' diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 433087eff3..ac901d6a99 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -29,17 +29,17 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-user-id": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-user-id": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index 7f0bb3a59f..37205b76e2 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-command-feedback */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' import type { Session } from '@deepseek-ai/dsh-session' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' diff --git a/packages/feedback/command-feedback/src/invariant.ts b/packages/feedback/command-feedback/src/invariant.ts index 9c825a6e87..6f4c420ec6 100644 --- a/packages/feedback/command-feedback/src/invariant.ts +++ b/packages/feedback/command-feedback/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-command-feedback' diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 6f93ff854e..453d9c17fc 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 958b23736f..2060fe2207 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index 50135f0f50..1401a25597 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -27,16 +27,16 @@ "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "koffi": "^3.1.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 5d1f6b2865..5be06c3d1c 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -4,11 +4,11 @@ * @module @deepseek-ai/dsh-fs-local */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { constants as bufferConstants } from 'node:buffer' import { isAbsolute, relative, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, diff --git a/packages/fs/fs-local/src/invariant.ts b/packages/fs/fs-local/src/invariant.ts index 3e38550065..07a8a62246 100644 --- a/packages/fs/fs-local/src/invariant.ts +++ b/packages/fs/fs-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-local' diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index d70581f28e..03bad054ec 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -12,7 +12,7 @@ import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' import { FsVersion } from '@deepseek-ai/dsh-fs' import type { FsTarget } from '@deepseek-ai/dsh-fs' diff --git a/packages/fs/fs-policy/README.i18n.yaml b/packages/fs/fs-policy/README.i18n.yaml index 65239a6f00..d889e039de 100644 --- a/packages/fs/fs-policy/README.i18n.yaml +++ b/packages/fs/fs-policy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/fs-policy/README.md -README.md: 395a36e89e113dc3846a8dff62ce90601013addd -README.zh.md: 5b3b1f64de2d51f6b729392da2d4b459a9f0ca3b +README.md: 59865d063095d666244c2ec86b8604f006f70554 +README.zh.md: af84614194db2afef3865a62e1030a2849e48947 diff --git a/packages/fs/fs-policy/README.md b/packages/fs/fs-policy/README.md index 395a36e89e..59865d0630 100644 --- a/packages/fs/fs-policy/README.md +++ b/packages/fs/fs-policy/README.md @@ -5,7 +5,7 @@ English | [中文](README.zh.md) The **fs-policy plugin**: it records observed presence or absence and adds read-before-edit plus guarded write/edit on top of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class. ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' declare const ctx: Context diff --git a/packages/fs/fs-policy/README.zh.md b/packages/fs/fs-policy/README.zh.md index 5b3b1f64de..af84614194 100644 --- a/packages/fs/fs-policy/README.zh.md +++ b/packages/fs/fs-policy/README.zh.md @@ -5,7 +5,7 @@ **fs-policy 插件**:它记录观测到的存在或缺失状态,并在 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))之上增加编辑前读取和带防护的写入/编辑;它通过 `fs/*` 事件门禁参与,**不是**通过方法服务。该插件**不**注册 `ctx.fsPolicy` 服务,也没有公开的 `read`/`write`/`edit`/`resolve` 方法。它是文件系统栈的政策层:不是可替换 seam,而是不应位于 `FileSystem` 提供方基类上的政策。 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' declare const ctx: Context diff --git a/packages/fs/fs-policy/package.json b/packages/fs/fs-policy/package.json index 634fb114f5..007ef3d529 100644 --- a/packages/fs/fs-policy/package.json +++ b/packages/fs/fs-policy/package.json @@ -27,12 +27,12 @@ "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/fs/fs-policy/src/index.ts b/packages/fs/fs-policy/src/index.ts index c7feb8c484..91a9b4ec95 100644 --- a/packages/fs/fs-policy/src/index.ts +++ b/packages/fs/fs-policy/src/index.ts @@ -7,7 +7,7 @@ * @module @deepseek-ai/dsh-fs-policy */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { FsError } from '@deepseek-ai/dsh-fs' import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' import type { FsPolicyExec } from './types.ts' diff --git a/packages/fs/fs-policy/src/invariant.ts b/packages/fs/fs-policy/src/invariant.ts index 369fa5ea84..cd0ff6d2a4 100644 --- a/packages/fs/fs-policy/src/invariant.ts +++ b/packages/fs/fs-policy/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-policy' diff --git a/packages/fs/fs-policy/tests/policy.spec.ts b/packages/fs/fs-policy/tests/policy.spec.ts index 2c977cc3be..27dad3b5fc 100644 --- a/packages/fs/fs-policy/tests/policy.spec.ts +++ b/packages/fs/fs-policy/tests/policy.spec.ts @@ -1,7 +1,7 @@ /** Event-level policy tests; no filesystem provider is needed because the plugin performs no I/O. */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsObservation, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 08d63895d0..015a572c19 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -30,7 +30,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", @@ -38,6 +38,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/fs/fs-sandbox/src/index.ts b/packages/fs/fs-sandbox/src/index.ts index 8fa3654d62..9a0fa3104e 100644 --- a/packages/fs/fs-sandbox/src/index.ts +++ b/packages/fs/fs-sandbox/src/index.ts @@ -30,7 +30,7 @@ * @module @deepseek-ai/dsh-fs-sandbox */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local' import { FsError } from '@deepseek-ai/dsh-fs' diff --git a/packages/fs/fs-sandbox/src/invariant.ts b/packages/fs/fs-sandbox/src/invariant.ts index 93806bd519..8a42c86c90 100644 --- a/packages/fs/fs-sandbox/src/invariant.ts +++ b/packages/fs/fs-sandbox/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-sandbox' diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index 62648e1362..f6aaa0dd81 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -13,7 +13,7 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promis import { existsSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join, parse } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs' import type { FsTarget } from '@deepseek-ai/dsh-fs' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 14989c2f77..c8445419e1 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -29,13 +29,13 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 0be94dccc4..569342ab7e 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-fs */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { FsDirEntry, @@ -41,7 +41,7 @@ export type { FsWriteOutcome, } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { fs: FileSystem } diff --git a/packages/fs/fs/src/invariant.ts b/packages/fs/fs/src/invariant.ts index 62fec9a53c..ec5a22e021 100644 --- a/packages/fs/fs/src/invariant.ts +++ b/packages/fs/fs/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned filesystem event-data invariants. @module @deepseek-ai/dsh-fs/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { FsObservation, FsTarget } from './types.ts' diff --git a/packages/fs/fs/tests/invariant.spec.ts b/packages/fs/fs/tests/invariant.spec.ts index eecdea8766..a63606781a 100644 --- a/packages/fs/fs/tests/invariant.spec.ts +++ b/packages/fs/fs/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsTarget } from '@deepseek-ai/dsh-fs' import * as FsInvariant from '@deepseek-ai/dsh-fs/invariant' diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 2340871304..7260af2955 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index c0fb5d49d3..ab8a988ef6 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -26,7 +26,7 @@ "license": "BSD-3-Clause", "dependencies": { "@vscode/ripgrep": "^1.18.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", @@ -38,7 +38,7 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -52,6 +52,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" } } diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 3eeea9c75c..50a432b0bb 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-tool-fs-search/glob */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { sep } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index c7e8ec7ee4..81f76dfa09 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -11,7 +11,7 @@ * @module @deepseek-ai/dsh-tool-fs-search/grep */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { RetainedItems } from '@deepseek-ai/dsh-retention' diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index cf0a8db066..1f1d86556b 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -26,8 +26,8 @@ * @module @deepseek-ai/dsh-tool-fs-search */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts' import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts' diff --git a/packages/fs/tool-fs-search/src/invariant.ts b/packages/fs/tool-fs-search/src/invariant.ts index f7f206896d..26d14775ba 100644 --- a/packages/fs/tool-fs-search/src/invariant.ts +++ b/packages/fs/tool-fs-search/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs-search' diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 854c593190..ad8693a67c 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -20,7 +20,7 @@ */ import { isAbsolute, relative, sep } from 'node:path' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention' import type { RetainedItems } from '@deepseek-ai/dsh-retention' diff --git a/packages/fs/tool-fs-search/src/surface.ts b/packages/fs/tool-fs-search/src/surface.ts index 78bdd6cc42..0db8887c0c 100644 --- a/packages/fs/tool-fs-search/src/surface.ts +++ b/packages/fs/tool-fs-search/src/surface.ts @@ -1,6 +1,6 @@ /** Shared surface-only post-policy selection for search result spill. @module dsh-tool-fs-search/surface */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { JsonValue, PostToolDecision, ToolDefinition, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' /** diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index dc7a88b30f..5087fc96f0 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -14,7 +14,7 @@ import { existsSync } from 'node:fs' import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' diff --git a/packages/fs/tool-fs-search/tests/load-path.spec.ts b/packages/fs/tool-fs-search/tests/load-path.spec.ts index 1d1e348c09..7dcd1aea6b 100644 --- a/packages/fs/tool-fs-search/tests/load-path.spec.ts +++ b/packages/fs/tool-fs-search/tests/load-path.spec.ts @@ -15,8 +15,8 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' diff --git a/packages/fs/tool-fs-search/tests/rg-path.spec.ts b/packages/fs/tool-fs-search/tests/rg-path.spec.ts index 52888a3453..3ad75edaaf 100644 --- a/packages/fs/tool-fs-search/tests/rg-path.spec.ts +++ b/packages/fs/tool-fs-search/tests/rg-path.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { resolveRgPath, runRipgrep } from '@deepseek-ai/dsh-tool-fs-search' diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index c02e4f93db..c4ea89345f 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -11,7 +11,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { join, sep } from 'node:path' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 8af8bc77ba..b2070827cb 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -26,7 +26,7 @@ "license": "BSD-3-Clause", "dependencies": { "diff": "^9.0.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", @@ -38,7 +38,7 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -56,6 +56,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index a7b82bdd06..e74f507f3d 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-tool-fs/src/edit */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-fs' diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index a4c96d606b..c6a311f686 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-tool-fs */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-user-approval' import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts' import { applyWriteTool } from './write.ts' diff --git a/packages/fs/tool-fs/src/invariant.ts b/packages/fs/tool-fs/src/invariant.ts index eaa2485c06..8cbbe4f423 100644 --- a/packages/fs/tool-fs/src/invariant.ts +++ b/packages/fs/tool-fs/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs' diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index dc4f07e8fe..0c062c129e 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-tool-fs/src/read */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ReadResultView, ToolResult } from '@deepseek-ai/dsh-tools' import { FsError } from '@deepseek-ai/dsh-fs' diff --git a/packages/fs/tool-fs/src/sandbox.ts b/packages/fs/tool-fs/src/sandbox.ts index ca824ceea5..79a2381441 100644 --- a/packages/fs/tool-fs/src/sandbox.ts +++ b/packages/fs/tool-fs/src/sandbox.ts @@ -10,7 +10,7 @@ * @module @deepseek-ai/dsh-tool-fs/sandbox */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index ba96e32cd1..12f0ef2652 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-tool-fs/src/write */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs' diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index 290d7125a7..387fc2410c 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { SessionId } from '@deepseek-ai/dsh-session' import { fsHarness, waitForIdle } from './harness.ts' diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts index 0d700e61b6..24a8b4d606 100644 --- a/packages/fs/tool-fs/tests/harness.ts +++ b/packages/fs/tool-fs/tests/harness.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index d2a63e0a38..c3845f76c2 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index b217260972..73be4131e1 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index b9404e20d6..04ad220a3a 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -29,10 +29,10 @@ "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "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:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 4b93c6d82c..c8afd16064 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -4,8 +4,8 @@ */ import { isAbsolute } from 'node:path' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { FsError } from '@deepseek-ai/dsh-fs' import type { FsInfo, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs' import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' diff --git a/packages/fs/tool-str-replace-editor/src/invariant.ts b/packages/fs/tool-str-replace-editor/src/invariant.ts index 99547c02ee..c2b6d8f149 100644 --- a/packages/fs/tool-str-replace-editor/src/invariant.ts +++ b/packages/fs/tool-str-replace-editor/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-str-replace-editor' diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index db0fe8dc35..987f78b69b 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { FsVersion } from '@deepseek-ai/dsh-fs' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index 7313daa4af..daf1ebf924 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -28,16 +28,16 @@ "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-goal": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/goal/command-goal/src/index.ts b/packages/goal/command-goal/src/index.ts index 93ed7923b8..38d18e2529 100644 --- a/packages/goal/command-goal/src/index.ts +++ b/packages/goal/command-goal/src/index.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-command-goal */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' import { GoalError } from '@deepseek-ai/dsh-goal' import type { GoalPhase, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' diff --git a/packages/goal/command-goal/src/invariant.ts b/packages/goal/command-goal/src/invariant.ts index 795294b4e8..673d6a27ef 100644 --- a/packages/goal/command-goal/src/invariant.ts +++ b/packages/goal/command-goal/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-command-goal' diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 03bf965fd6..9479859e7d 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' diff --git a/packages/goal/goal-session/package.json b/packages/goal/goal-session/package.json index 86b58f3cda..bd315cb2c1 100644 --- a/packages/goal/goal-session/package.json +++ b/packages/goal/goal-session/package.json @@ -30,7 +30,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -42,6 +42,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index 64c90e0c35..1d78dc5a50 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -4,8 +4,8 @@ */ import { isDeepStrictEqual } from 'node:util' -import { FiberState } from 'cordis' -import type { Context } from 'cordis' +import { FiberState } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/goal/goal-session/src/invariant.ts b/packages/goal/goal-session/src/invariant.ts index 53cdd9b20b..121343dfff 100644 --- a/packages/goal/goal-session/src/invariant.ts +++ b/packages/goal/goal-session/src/invariant.ts @@ -1,7 +1,7 @@ /** Package-owned goal-round prompt invariants. @module @deepseek-ai/dsh-goal-session/invariant */ import { isDeepStrictEqual } from 'node:util' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { foldGoal, type FoldedGoal, type GoalMessageSource, type GoalView } from '@deepseek-ai/dsh-goal' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 2d14abf158..1231e659c2 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { agentEvents } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' diff --git a/packages/goal/goal-session/tests/invariant.spec.ts b/packages/goal/goal-session/tests/invariant.spec.ts index 6baaa724da..76830591e3 100644 --- a/packages/goal/goal-session/tests/invariant.spec.ts +++ b/packages/goal/goal-session/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { GoalId, type GoalSnapshotChangeMeta, diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index fccf7de3be..32ca4d00d9 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -56,10 +56,10 @@ "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-type-meta": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.17.2", + "@deepseek-ai/schemastery": "^3.17.2", "zod": "^4.4.3" }, "devDependencies": { @@ -72,6 +72,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/goal/goal/src/domain.ts b/packages/goal/goal/src/domain.ts index 3dc5672f0b..63f1573dc7 100644 --- a/packages/goal/goal/src/domain.ts +++ b/packages/goal/goal/src/domain.ts @@ -101,7 +101,7 @@ export type GoalErrorCode = | 'GOAL_INVALID_EDIT' | 'GOAL_INVALID_TRANSITION' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { /** * Goal mutation accepted by one live agent. The matching `goal/change` diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 6667463d86..c4f5d70ee8 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -5,8 +5,8 @@ */ import { randomUUID } from 'node:crypto' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { ZodType } from 'zod' import { agentEvents } from '@deepseek-ai/dsh-agent' @@ -56,7 +56,7 @@ export type * from './domain.ts' export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts' export { decodeGoalChange, foldGoal, goalChangeRef } from './fold.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { goals: GoalService } diff --git a/packages/goal/goal/src/invariant.ts b/packages/goal/goal/src/invariant.ts index 42c83c65f0..31ac8e2f67 100644 --- a/packages/goal/goal/src/invariant.ts +++ b/packages/goal/goal/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned durable goal-stream invariants. @module @deepseek-ai/dsh-goal/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { applyGoalEvent, emptyGoalFoldState } from './fold.ts' diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 0c0a8e8373..e94e0754a0 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' diff --git a/packages/goal/goal/tests/invariant.spec.ts b/packages/goal/goal/tests/invariant.spec.ts index b342036f83..79d7f10704 100644 --- a/packages/goal/goal/tests/invariant.spec.ts +++ b/packages/goal/goal/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { GoalId, type GoalSnapshotChangeMeta, diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 7dcab0dc40..6282a6f739 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -9,7 +9,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index c7c87e44db..0b03bb02b6 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -32,13 +32,13 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -46,6 +46,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index ad0fe9affd..1fdfbc740a 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -1,6 +1,6 @@ /** Execution-time authority checks for the model-facing goal tools. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index d22ff26dc2..903190de4d 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-tool-goal */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' import { boundContextSummary, createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' diff --git a/packages/goal/tool-goal/src/invariant.ts b/packages/goal/tool-goal/src/invariant.ts index d3ea60f049..28b9637772 100644 --- a/packages/goal/tool-goal/src/invariant.ts +++ b/packages/goal/tool-goal/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-goal' diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index d8bc950019..71469bc5c6 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' diff --git a/packages/guard/repeat-tool-guard/package.json b/packages/guard/repeat-tool-guard/package.json index 9978865a5d..9bc4fd493f 100644 --- a/packages/guard/repeat-tool-guard/package.json +++ b/packages/guard/repeat-tool-guard/package.json @@ -25,13 +25,13 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index 464dd318da..efc3914592 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-repeat-tool-guard */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' diff --git a/packages/guard/repeat-tool-guard/src/invariant.ts b/packages/guard/repeat-tool-guard/src/invariant.ts index 5d8544b9aa..29363695a2 100644 --- a/packages/guard/repeat-tool-guard/src/invariant.ts +++ b/packages/guard/repeat-tool-guard/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-repeat-tool-guard' diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 20cd9a0104..a8eda4cf4c 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index b77bd23716..3f56d8e59e 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -29,13 +29,13 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/guard/timeout-policy/src/index.ts b/packages/guard/timeout-policy/src/index.ts index 1ff88b46a9..11c8f986d7 100644 --- a/packages/guard/timeout-policy/src/index.ts +++ b/packages/guard/timeout-policy/src/index.ts @@ -11,7 +11,7 @@ * @module @deepseek-ai/dsh-timeout-policy */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' diff --git a/packages/guard/timeout-policy/src/invariant.ts b/packages/guard/timeout-policy/src/invariant.ts index ddc3b3966e..ef2a25e6ae 100644 --- a/packages/guard/timeout-policy/src/invariant.ts +++ b/packages/guard/timeout-policy/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-timeout-policy' diff --git a/packages/guard/timeout-policy/tests/timeout-policy.spec.ts b/packages/guard/timeout-policy/tests/timeout-policy.spec.ts index c2d55d044c..cd8085785b 100644 --- a/packages/guard/timeout-policy/tests/timeout-policy.spec.ts +++ b/packages/guard/timeout-policy/tests/timeout-policy.spec.ts @@ -7,8 +7,8 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools' diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 0e35d83513..670cd4df5d 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -28,12 +28,12 @@ "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 8a7c7338c3..d4a36dc9f1 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned hook invocation/result stream invariants. @module @deepseek-ai/dsh-hook-protocol/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type {} from './types.ts' diff --git a/packages/hooks/hook-protocol/tests/invariant.spec.ts b/packages/hooks/hook-protocol/tests/invariant.spec.ts index 94e5b57847..9ec212e5de 100644 --- a/packages/hooks/hook-protocol/tests/invariant.spec.ts +++ b/packages/hooks/hook-protocol/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import * as HookInvariant from '@deepseek-ai/dsh-hook-protocol/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index cce1d3edb9..2af6a96466 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -25,7 +25,7 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", @@ -36,7 +36,7 @@ "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -53,6 +53,6 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index cb86d86c92..ae28cf00a0 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -10,8 +10,8 @@ */ import { readFileSync } from 'node:fs' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' diff --git a/packages/hooks/hooks-claude/src/invariant.ts b/packages/hooks/hooks-claude/src/invariant.ts index d7908419d4..cdf3fb3ec3 100644 --- a/packages/hooks/hooks-claude/src/invariant.ts +++ b/packages/hooks/hooks-claude/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-claude' diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 244abae423..86b2692493 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -3,8 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context, type Fiber } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context, type Fiber } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index c82f0320f3..9411248d22 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 078b58fb08..37ac4d7bed 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -25,7 +25,7 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", @@ -35,7 +35,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -51,6 +51,6 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index d31d9f8c48..3b7d0d7a40 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -13,8 +13,8 @@ // point; a cross-package facade for imports alone would add indirection. /* jscpd:ignore-start */ import { readFileSync } from 'node:fs' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' diff --git a/packages/hooks/hooks-codex/src/invariant.ts b/packages/hooks/hooks-codex/src/invariant.ts index c3eacb7ab0..5d19efb7b0 100644 --- a/packages/hooks/hooks-codex/src/invariant.ts +++ b/packages/hooks/hooks-codex/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-codex' diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 0878397edd..cdfbe2c594 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -3,8 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index d92ea597bd..8690af565e 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 42f69c6211..5ce28e9d05 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -62,13 +62,13 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "^3.18.0", "zod": "^4.4.3" }, "peerDependencies": { "@deepseek-ai/dsh-agent-presets": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", @@ -77,6 +77,6 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 3626ef9960..ea41122cc9 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -6,7 +6,7 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' import { dirname } from 'node:path' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model' diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 43ce9e2df4..9290005a52 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -12,8 +12,8 @@ * service; sessions that have already logged a selection remain unchanged. */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent-default-model' import type { ApiProxy } from './api/index.ts' import { createApiProxy } from './api-proxy.ts' @@ -26,7 +26,7 @@ export type { IApiClient } from './fetch/client.ts' export { createApiProxy } from './api-proxy.ts' export type { ApiProxyDefaults } from './api-proxy.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The host-side ApiProxy implementation (the transport-agnostic gateway face). */ apiProxy: ApiProxy diff --git a/packages/host/apiproxy/src/invariant.ts b/packages/host/apiproxy/src/invariant.ts index a96b5d081d..ac21250e90 100644 --- a/packages/host/apiproxy/src/invariant.ts +++ b/packages/host/apiproxy/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-host-apiproxy' diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 106cb213da..d1bfaba00d 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -8,7 +8,7 @@ import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index 1cee4cd4e0..8aaa44564b 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index e01282043b..066c47f376 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts @@ -8,7 +8,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 78c748861a..126ce506b8 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -8,7 +8,7 @@ import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 5f0af7cc5c..038c1050f7 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -10,7 +10,7 @@ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm' */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index b8b067cae3..286a6d3f4f 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -6,8 +6,8 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index d1bf611fbb..e1d4dc5aa4 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -1,7 +1,7 @@ /** Session-fork boundaries, lineage, and inherited model routing. */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index bdd21128b4..335b8b795f 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index 45335e131b..a3a8867291 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -8,7 +8,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts index 2ca7a31d12..fed6a5435e 100644 --- a/packages/host/apiproxy/tests/api-proxy-question.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 346ed9f313..bf2a7e3ba0 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -8,7 +8,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 7637399f50..c945020eb1 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { stat } from 'node:fs/promises' import AgentRegistry from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index eb39bb6791..02f194eede 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { SubagentError } from '@deepseek-ai/dsh-subagent' import { RpcId } from '../src/api/rpc.ts' diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 86fb593f4b..eabac2056b 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -8,7 +8,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index ddb53cf02f..a8f12641da 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index 753f83845a..b5e5164500 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -25,21 +25,21 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@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", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 3cf75b20dc..91343463fd 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -10,9 +10,9 @@ * @module @deepseek-ai/dsh-host-directory-picker-auto */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' // Empty type imports carry the `loader` and `httpServer` Context merges for the reads below. -import type {} from '@cordisjs/plugin-loader' +import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' import { canExecute, hasLinuxChooserBinary } from './probe.ts' import type { DirectoryPickerBackendKind } from './resolve.ts' diff --git a/packages/host/directory-picker-auto/src/invariant.ts b/packages/host/directory-picker-auto/src/invariant.ts index 8b3f251447..c31102a752 100644 --- a/packages/host/directory-picker-auto/src/invariant.ts +++ b/packages/host/directory-picker-auto/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-host-directory-picker-auto/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-auto' diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 2ab50d6e80..ed9bfd1e44 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -13,9 +13,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import HttpServer from '@deepseek-ai/dsh-host-webserver' import type { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker' import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse' diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 85256ef7dc..04078b9ea7 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -32,7 +32,7 @@ "dependencies": { "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "clsx": "^2.0.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "^0.0.1", @@ -41,7 +41,7 @@ "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-client-ui-workspace": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -53,7 +53,7 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "dsh": { diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index e84f51b26a..adbe6d09ad 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -12,8 +12,8 @@ import { mkdir, opendir, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, join, posix, resolve, win32 } from 'node:path' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { DirectoryPicker, DirectoryPickerError, } from '@deepseek-ai/dsh-host-directory-picker' diff --git a/packages/host/directory-picker-browse/src/invariant.ts b/packages/host/directory-picker-browse/src/invariant.ts index ba4bfe7b13..7170de7809 100644 --- a/packages/host/directory-picker-browse/src/invariant.ts +++ b/packages/host/directory-picker-browse/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-host-directory-picker-browse/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-browse' diff --git a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx index e7a07fb63a..69ea819564 100644 --- a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 002d42e516..a969845636 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { basename, join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import type { DirectoryPickerBrowseCapability } from '@deepseek-ai/dsh-host-directory-picker' import BrowseDirectoryPicker, { boundedInsert, fullyQualified, raceAbort } from '../src/index.ts' diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index c8e4e4003b..b725cd1001 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -44,7 +44,7 @@ "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-client-ui-workspace": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { @@ -53,7 +53,7 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "react": "^18.2.0", "tsx": "^4.19.2" }, diff --git a/packages/host/directory-picker-native/src/invariant.ts b/packages/host/directory-picker-native/src/invariant.ts index 777acd57dd..41b77ddb1c 100644 --- a/packages/host/directory-picker-native/src/invariant.ts +++ b/packages/host/directory-picker-native/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-host-directory-picker-native/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-native' diff --git a/packages/host/directory-picker-native/tests/client-flow.spec.tsx b/packages/host/directory-picker-native/tests/client-flow.spec.tsx index 34eb5e40b4..d501fdfce2 100644 --- a/packages/host/directory-picker-native/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-native/tests/client-flow.spec.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' diff --git a/packages/host/directory-picker-native/tests/service.spec.ts b/packages/host/directory-picker-native/tests/service.spec.ts index 61b5adddaf..a05100f5cb 100644 --- a/packages/host/directory-picker-native/tests/service.spec.ts +++ b/packages/host/directory-picker-native/tests/service.spec.ts @@ -1,7 +1,7 @@ /** Registration/capability behavior of the native backend (the seam's cordis half). */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import NativeDirectoryPicker from '../src/index.ts' describe('NativeDirectoryPicker', () => { diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index c37b67a4d7..c348021815 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -26,10 +26,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 4a0a831247..5e8eb64f87 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -11,7 +11,7 @@ * @module @deepseek-ai/dsh-host-directory-picker */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' /** The native interaction: one OS directory chooser on the host display. */ export interface DirectoryPickerNativeCapability { @@ -115,7 +115,7 @@ export class DirectoryPickerError extends Error { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { directoryPicker: DirectoryPicker } diff --git a/packages/host/directory-picker/src/invariant.ts b/packages/host/directory-picker/src/invariant.ts index 05638bd0cf..abf29856fc 100644 --- a/packages/host/directory-picker/src/invariant.ts +++ b/packages/host/directory-picker/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned invariant companion for the directory-picker seam. @module @deepseek-ai/dsh-host-directory-picker/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker' diff --git a/packages/host/directory-picker/tests/seam.spec.ts b/packages/host/directory-picker/tests/seam.spec.ts index 52722b9b0d..6fd6b48fa9 100644 --- a/packages/host/directory-picker/tests/seam.spec.ts +++ b/packages/host/directory-picker/tests/seam.spec.ts @@ -1,7 +1,7 @@ /** Contract behavior the seam itself owns: registration identity and typed failures. */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { DirectoryPicker, DirectoryPickerError } from '../src/index.ts' import type { DirectoryPickerCapability } from '../src/index.ts' diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index ac690cee24..73414a5422 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -27,15 +27,15 @@ "peerDependencies": { "@deepseek-ai/dsh-host-webserver": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts index 8bd5b829c1..d42cf59301 100644 --- a/packages/host/frontend-static/src/index.ts +++ b/packages/host/frontend-static/src/index.ts @@ -14,8 +14,8 @@ import type { ServerResponse } from 'node:http' import { readFile } from 'node:fs/promises' import { dirname, extname, join, normalize, resolve, sep } from 'node:path' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-host-webserver' /** Stable Cordis plugin name. */ diff --git a/packages/host/frontend-static/src/invariant.ts b/packages/host/frontend-static/src/invariant.ts index 551daccbc8..567ff852ec 100644 --- a/packages/host/frontend-static/src/invariant.ts +++ b/packages/host/frontend-static/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-frontend-static/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-frontend-static' diff --git a/packages/host/frontend-static/tests/frontend-static.spec.ts b/packages/host/frontend-static/tests/frontend-static.spec.ts index 4f5fa0d2c7..16d75f88cd 100644 --- a/packages/host/frontend-static/tests/frontend-static.spec.ts +++ b/packages/host/frontend-static/tests/frontend-static.spec.ts @@ -11,9 +11,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import HttpServer from '@deepseek-ai/dsh-host-webserver' import * as FrontendStatic from '../src/index.ts' diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 4db9433935..e7a4b4cbf9 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -25,14 +25,14 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "@deepseek-ai/dsh-invariants": "^0.0.1" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "@deepseek-ai/dsh-invariants": "workspace:^" } } diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 2ff04379e3..e370d12224 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -12,10 +12,10 @@ import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse, Server } from 'node:http' import type { AddressInfo } from 'node:net' import type { Duplex } from 'node:stream' -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { httpServer: HttpServerService } diff --git a/packages/host/webserver/src/invariant.ts b/packages/host/webserver/src/invariant.ts index 7becf3543b..036fa03932 100644 --- a/packages/host/webserver/src/invariant.ts +++ b/packages/host/webserver/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-host-webserver' diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index d91284c87b..1a592cd432 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -12,9 +12,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import HttpServer from '../src/index.ts' let root: string | undefined diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index cd62942a5f..96fd3e6740 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -39,7 +39,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -47,6 +47,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index 9f4110568e..6b13ec549a 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-commands */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' @@ -119,7 +119,7 @@ class CommandLayer implements ScopeLayer { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { commands: CommandService } diff --git a/packages/interaction/commands/src/invariant.ts b/packages/interaction/commands/src/invariant.ts index 792733c199..ff5d60a7cb 100644 --- a/packages/interaction/commands/src/invariant.ts +++ b/packages/interaction/commands/src/invariant.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-commands/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index 54b4227d19..7b39db932a 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/interaction/commands/tests/invariant.spec.ts b/packages/interaction/commands/tests/invariant.spec.ts index 556be701e8..60bbe20696 100644 --- a/packages/interaction/commands/tests/invariant.spec.ts +++ b/packages/interaction/commands/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as CommandInvariant from '@deepseek-ai/dsh-commands/invariant' import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' diff --git a/packages/interaction/permission/package.json b/packages/interaction/permission/package.json index 0ae48d6e2f..5fad3f9d25 100644 --- a/packages/interaction/permission/package.json +++ b/packages/interaction/permission/package.json @@ -43,10 +43,10 @@ "@deepseek-ai/dsh-session-projection": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "^3.18.0", "zod": "^4.4.3" }, "devDependencies": { @@ -59,6 +59,6 @@ "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/interaction/permission/src/index.ts b/packages/interaction/permission/src/index.ts index 35b762b2aa..a68431aeb5 100644 --- a/packages/interaction/permission/src/index.ts +++ b/packages/interaction/permission/src/index.ts @@ -10,8 +10,8 @@ * @module dsh-permission */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' @@ -33,7 +33,7 @@ import type { PermissionSelect, PresetOption } from './types.ts' // consuming the declarations still receive the SessionProjectionMap merge. export type * from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { permission: PermissionService } diff --git a/packages/interaction/permission/src/invariant.ts b/packages/interaction/permission/src/invariant.ts index 3bd102645f..b403c9e576 100644 --- a/packages/interaction/permission/src/invariant.ts +++ b/packages/interaction/permission/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned permission-preset event invariants. @module @deepseek-ai/dsh-permission/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/interaction/permission/tests/invariant.spec.ts b/packages/interaction/permission/tests/invariant.spec.ts index 9b903dc6a5..7c916f5b58 100644 --- a/packages/interaction/permission/tests/invariant.spec.ts +++ b/packages/interaction/permission/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import * as PermissionInvariant from '@deepseek-ai/dsh-permission/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/interaction/permission/tests/permission.spec.ts b/packages/interaction/permission/tests/permission.spec.ts index 4940e3b155..d20f513b04 100644 --- a/packages/interaction/permission/tests/permission.spec.ts +++ b/packages/interaction/permission/tests/permission.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' diff --git a/packages/interaction/permission/tests/projection.spec.ts b/packages/interaction/permission/tests/projection.spec.ts index 3a0e2091c9..e9bb098967 100644 --- a/packages/interaction/permission/tests/projection.spec.ts +++ b/packages/interaction/permission/tests/projection.spec.ts @@ -10,7 +10,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index 7418c7ba2c..b7291d4ccc 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -29,7 +29,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -38,6 +38,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/interaction/tool-ask-user/src/index.ts b/packages/interaction/tool-ask-user/src/index.ts index ccfdefe6d3..f223053171 100644 --- a/packages/interaction/tool-ask-user/src/index.ts +++ b/packages/interaction/tool-ask-user/src/index.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-tool-ask-user */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import '@deepseek-ai/dsh-user-interaction' diff --git a/packages/interaction/tool-ask-user/src/invariant.ts b/packages/interaction/tool-ask-user/src/invariant.ts index 140bbd79c5..d723a4bc31 100644 --- a/packages/interaction/tool-ask-user/src/invariant.ts +++ b/packages/interaction/tool-ask-user/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-ask-user' diff --git a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts index 14e71ae15b..2795ffa3ff 100644 --- a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 5b5c43cb42..854e316df9 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -37,10 +37,10 @@ "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -50,6 +50,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index f0b63080ed..b0618f6b3d 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -5,8 +5,8 @@ */ import { randomUUID } from 'node:crypto' -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' @@ -14,7 +14,7 @@ import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { approval: ApprovalService } diff --git a/packages/interaction/user-approval/src/invariant.ts b/packages/interaction/user-approval/src/invariant.ts index 6eca2571ff..bf3ca8d18d 100644 --- a/packages/interaction/user-approval/src/invariant.ts +++ b/packages/interaction/user-approval/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned approval audit-stream invariants. @module @deepseek-ai/dsh-user-approval/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ApprovalRequestId } from './index.ts' diff --git a/packages/interaction/user-approval/tests/approval.spec.ts b/packages/interaction/user-approval/tests/approval.spec.ts index 2bb66caa9d..3271ecc7a5 100644 --- a/packages/interaction/user-approval/tests/approval.spec.ts +++ b/packages/interaction/user-approval/tests/approval.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope' diff --git a/packages/interaction/user-approval/tests/invariant.spec.ts b/packages/interaction/user-approval/tests/invariant.spec.ts index bf23b4106d..42665b81f2 100644 --- a/packages/interaction/user-approval/tests/invariant.spec.ts +++ b/packages/interaction/user-approval/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' import * as ApprovalInvariant from '@deepseek-ai/dsh-user-approval/invariant' diff --git a/packages/interaction/user-interaction/package.json b/packages/interaction/user-interaction/package.json index 5bed79e075..c72e3f1298 100644 --- a/packages/interaction/user-interaction/package.json +++ b/packages/interaction/user-interaction/package.json @@ -33,12 +33,12 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/interaction/user-interaction/src/index.ts b/packages/interaction/user-interaction/src/index.ts index 82ab06e25b..79c22b8479 100644 --- a/packages/interaction/user-interaction/src/index.ts +++ b/packages/interaction/user-interaction/src/index.ts @@ -7,11 +7,11 @@ * @module @deepseek-ai/dsh-user-interaction */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { userInteraction: UserInteractionService } diff --git a/packages/interaction/user-interaction/src/invariant.ts b/packages/interaction/user-interaction/src/invariant.ts index f4f2f2f31e..3e82a2f73e 100644 --- a/packages/interaction/user-interaction/src/invariant.ts +++ b/packages/interaction/user-interaction/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-user-interaction' diff --git a/packages/interaction/user-interaction/tests/user-interaction.spec.ts b/packages/interaction/user-interaction/tests/user-interaction.spec.ts index 8270b81bfd..44fc075233 100644 --- a/packages/interaction/user-interaction/tests/user-interaction.spec.ts +++ b/packages/interaction/user-interaction/tests/user-interaction.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import UserInteractionService, { UserInteractionError, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index e745fa1169..d2573696be 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -31,11 +31,11 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "eventsource-parser": "^3.1.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-credentials": "workspace:^", @@ -44,6 +44,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 6d052edc45..f77b36a9cc 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -11,8 +11,8 @@ * @module @deepseek-ai/dsh-llm-deepseek */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { assertUsableApiKey, LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' diff --git a/packages/llm/llm-deepseek/src/invariant.ts b/packages/llm/llm-deepseek/src/invariant.ts index dd2df6e99c..921e02a252 100644 --- a/packages/llm/llm-deepseek/src/invariant.ts +++ b/packages/llm/llm-deepseek/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-deepseek' diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 97ae629001..e8eeb7b403 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 64e2bb1e7d..32a19776b2 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createEnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, diff --git a/packages/llm/llm-deepseek/tests/assemble.ts b/packages/llm/llm-deepseek/tests/assemble.ts index 490b4e87cf..2ed51d0f3d 100644 --- a/packages/llm/llm-deepseek/tests/assemble.ts +++ b/packages/llm/llm-deepseek/tests/assemble.ts @@ -6,7 +6,7 @@ */ import { BlockAssembler } from '@deepseek-ai/dsh-llm' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { FinishReason, GenerateOptions, Message, TokenUsage } from '@deepseek-ai/dsh-llm' export interface AssembledResult { diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 769264b11b..ec6da80b17 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 340bed3ee9..d87c6bfe6c 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -13,9 +13,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import LlmService from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 9804a35a0f..f7a931d902 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -32,11 +32,11 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "@earendil-works/pi-ai": "^0.82.1", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", @@ -47,6 +47,6 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index a2074302e8..e6d2c0849b 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -15,7 +15,7 @@ */ import type { CacheRetention, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 9deea3e1a8..199c98bff3 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -55,7 +55,7 @@ * @module @deepseek-ai/dsh-llm-pi-ai */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm' import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' diff --git a/packages/llm/llm-pi-ai/src/invariant.ts b/packages/llm/llm-pi-ai/src/invariant.ts index a096804fd2..6fdf5b3126 100644 --- a/packages/llm/llm-pi-ai/src/invariant.ts +++ b/packages/llm/llm-pi-ai/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-pi-ai' diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index 4637a75298..1232ca3484 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index e0b54cecd0..1bc370f126 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, diff --git a/packages/llm/llm-pi-ai/tests/assemble.ts b/packages/llm/llm-pi-ai/tests/assemble.ts index 61726fd1d4..8d415941c7 100644 --- a/packages/llm/llm-pi-ai/tests/assemble.ts +++ b/packages/llm/llm-pi-ai/tests/assemble.ts @@ -6,7 +6,7 @@ */ import { BlockAssembler } from '@deepseek-ai/dsh-llm' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { FinishReason, GenerateOptions, Message, TokenUsage } from '@deepseek-ai/dsh-llm' export interface AssembledResult { diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index e304dddade..38c4184e0a 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import SettingsLocal from '@deepseek-ai/dsh-settings-local' diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index 02b859d5f2..d4efce4109 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -1,7 +1,7 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index d30cf18cf9..85987088a3 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index d5eed60e5e..e0800ed88d 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -13,9 +13,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import LlmService from '@deepseek-ai/dsh-llm' import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' import SettingsLocal from '@deepseek-ai/dsh-settings-local' diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 002de2b829..e64fb12377 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -1,6 +1,6 @@ import { readFile } from 'node:fs/promises' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 809b5d14e5..5f69487147 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -35,15 +35,15 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -57,6 +57,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 2ebeb79260..45db304059 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -6,8 +6,8 @@ */ import { randomUUID } from 'node:crypto' -import type { Context, Events } from 'cordis' -import z from 'schemastery' +import type { Context, Events } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index 1680873c0c..7b454bcc4e 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned durable retry-event invariants. @module @deepseek-ai/dsh-llm-retry/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { LlmFailure } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index dd22e11369..eccaeef4d8 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 16fb8581b9..c560720c76 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmService, { createUserMessage, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 8affb9e059..de20b69698 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 7268c19ec6..62423ecfda 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' -import { Context } from 'cordis' -import type { Fiber } from 'cordis' +import { Context } from '@deepseek-ai/cordis' +import type { Fiber } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' import type { AlwaysRetryPolicyConfig, diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index 3acb8605fc..fb830f9868 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -2,7 +2,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index c802ce5c43..1a11622738 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -42,16 +42,16 @@ "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 75d8d6f364..9938d687ac 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-llm */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { GenerateOptions, LlmConfigurableProvider, @@ -43,7 +43,7 @@ export { BlockAssembler } from './assembler.ts' export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts' export type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { llm: LlmService } diff --git a/packages/llm/llm/src/invariant.ts b/packages/llm/llm/src/invariant.ts index 2f1afb5155..2106ca354d 100644 --- a/packages/llm/llm/src/invariant.ts +++ b/packages/llm/llm/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned LLM stream-protocol invariants. @module @deepseek-ai/dsh-llm/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ContentBlockType, StreamChunk } from './types.ts' diff --git a/packages/llm/llm/src/retry-policy.ts b/packages/llm/llm/src/retry-policy.ts index 6f5548a111..ad9c7af65c 100644 --- a/packages/llm/llm/src/retry-policy.ts +++ b/packages/llm/llm/src/retry-policy.ts @@ -7,7 +7,7 @@ * @module @deepseek-ai/dsh-llm/retry-policy */ -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { EMPTY_RESPONSE_CODE } from './error.ts' diff --git a/packages/llm/llm/tests/invariant.spec.ts b/packages/llm/llm/tests/invariant.spec.ts index 8aebb556c2..cfc51c67ee 100644 --- a/packages/llm/llm/tests/invariant.spec.ts +++ b/packages/llm/llm/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import * as LlmInvariant from '@deepseek-ai/dsh-llm/invariant' diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index a758fab415..8873d19859 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { errorChain, GenerateOptions, diff --git a/packages/llm/llm/tests/topology.spec.ts b/packages/llm/llm/tests/topology.spec.ts index 8577e14b7c..c31948f61a 100644 --- a/packages/llm/llm/tests/topology.spec.ts +++ b/packages/llm/llm/tests/topology.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmConfigurableProvider, StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 5670fe97cd..61cd66617f 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -35,10 +35,10 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-projection": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "^3.18.0", "zod": "^4.4.3" }, "devDependencies": { @@ -47,6 +47,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index a5ac463fb2..6d7d7ffe49 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-token-meter */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm' import type { Message, TokenUsage } from '@deepseek-ai/dsh-llm' import type { EpochHeader, Session, SessionEvent } from '@deepseek-ai/dsh-session' @@ -64,7 +64,7 @@ function validateConfigKeys(config: TokenMeterConfig): void { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { tokenMeter: TokenMeterService } diff --git a/packages/llm/token-meter/src/invariant.ts b/packages/llm/token-meter/src/invariant.ts index 53ae13c466..c65f4f27b8 100644 --- a/packages/llm/token-meter/src/invariant.ts +++ b/packages/llm/token-meter/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-token-meter' diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 9bdd1222f0..460b1a54f9 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -2,7 +2,7 @@ // plus the shared estimator's pricing branches. import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index e0bf348019..2ca24cf88d 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 9ccc2d7e9b..173bb8056b 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { TokenUsage } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json index a5411bab55..02fcddd558 100644 --- a/packages/lsp/lsp-local/package.json +++ b/packages/lsp/lsp-local/package.json @@ -32,10 +32,10 @@ "@deepseek-ai/dsh-lsp": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -47,7 +47,7 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "typescript": "^6.0.3", "typescript-language-server": "^5.0.0" } diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index fc7e50787f..13d032c5fd 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -11,8 +11,8 @@ * @module @deepseek-ai/dsh-lsp-local */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp' import type { LspProvider, diff --git a/packages/lsp/lsp-local/src/invariant.ts b/packages/lsp/lsp-local/src/invariant.ts index 52ebd16dc0..505d077eeb 100644 --- a/packages/lsp/lsp-local/src/invariant.ts +++ b/packages/lsp/lsp-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-lsp-local' diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts index 75f9f227c3..707f389a2f 100644 --- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -40,7 +40,7 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { it('runs a query through lib/index.js and disposes cleanly, framing over the base protocol', async () => { const location = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }) const script = ` - const { Context } = await import('cordis') + const { Context } = await import('@deepseek-ai/cordis') const { default: Lsp } = await import('@deepseek-ai/dsh-lsp') const LspLocal = await import('@deepseek-ai/dsh-lsp-local') const { default: LocalFileSystem } = await import('@deepseek-ai/dsh-fs-local') diff --git a/packages/lsp/lsp-local/tests/host.spec.ts b/packages/lsp/lsp-local/tests/host.spec.ts index c06b6d9df8..8c7a9079d3 100644 --- a/packages/lsp/lsp-local/tests/host.spec.ts +++ b/packages/lsp/lsp-local/tests/host.spec.ts @@ -6,7 +6,7 @@ import { realpath } from 'node:fs/promises' import { execFile } from 'node:child_process' import { pathToFileURL } from 'node:url' import { promisify } from 'node:util' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import { deadline } from '@deepseek-ai/dsh-timeout' import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local' diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 08bad67ae2..0a928fba50 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -4,7 +4,7 @@ import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promi import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local' import { encodeMessage } from '@deepseek-ai/dsh-lsp-local' diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 20c4bc7bf0..bda3963d7d 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -4,7 +4,7 @@ import { realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' import { deadline } from '@deepseek-ai/dsh-timeout' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 24edca4795..94486e1ea7 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' diff --git a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts index 9bdb7d1fd4..5879ed3b2f 100644 --- a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts +++ b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts @@ -9,7 +9,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index b4a962fb34..d5993b515e 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -28,12 +28,12 @@ "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/lsp/lsp/src/index.ts b/packages/lsp/lsp/src/index.ts index ec780b76f1..05e2cd3643 100644 --- a/packages/lsp/lsp/src/index.ts +++ b/packages/lsp/lsp/src/index.ts @@ -11,7 +11,7 @@ * @module @deepseek-ai/dsh-lsp */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { LspProviderId } from './brand.ts' import type { @@ -35,7 +35,7 @@ export type { LspService, } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { lsp: LspService } diff --git a/packages/lsp/lsp/src/invariant.ts b/packages/lsp/lsp/src/invariant.ts index 27481309f4..512775798b 100644 --- a/packages/lsp/lsp/src/invariant.ts +++ b/packages/lsp/lsp/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-lsp' diff --git a/packages/lsp/lsp/tests/lsp.spec.ts b/packages/lsp/lsp/tests/lsp.spec.ts index 86c9b0ac53..0adbf44769 100644 --- a/packages/lsp/lsp/tests/lsp.spec.ts +++ b/packages/lsp/lsp/tests/lsp.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import Lsp, { finalExtension, LspError, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index f757df35fd..7b7dc54dc2 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -31,10 +31,10 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -49,6 +49,6 @@ "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/lsp/tool-lsp/src/index.ts b/packages/lsp/tool-lsp/src/index.ts index 688b48b369..f94cb7d5f7 100644 --- a/packages/lsp/tool-lsp/src/index.ts +++ b/packages/lsp/tool-lsp/src/index.ts @@ -10,8 +10,8 @@ * @module @deepseek-ai/dsh-tool-lsp */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import { assertNever } from '@deepseek-ai/dsh-llm' import { LspError } from '@deepseek-ai/dsh-lsp' diff --git a/packages/lsp/tool-lsp/src/invariant.ts b/packages/lsp/tool-lsp/src/invariant.ts index a2516e059b..fc9a292fc8 100644 --- a/packages/lsp/tool-lsp/src/invariant.ts +++ b/packages/lsp/tool-lsp/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-lsp' diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index 8312a6e3ab..8ad2392adc 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -3,7 +3,7 @@ import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' diff --git a/packages/lsp/tool-lsp/tests/load-path.spec.ts b/packages/lsp/tool-lsp/tests/load-path.spec.ts index 7b3ec0f241..f5f2b272d7 100644 --- a/packages/lsp/tool-lsp/tests/load-path.spec.ts +++ b/packages/lsp/tool-lsp/tests/load-path.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import Loader from '@cordisjs/plugin-loader' +import Loader from '@deepseek-ai/cordis-plugin-loader' import * as toolLsp from '@deepseek-ai/dsh-tool-lsp' describe('dsh-tool-lsp Loader export-shape guard', () => { diff --git a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts index 846bd702ea..bc6795273e 100644 --- a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts +++ b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import Lsp, { LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp' diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 3f65271905..e5b110e7db 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -29,11 +29,11 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "^3.18.0", "zod": "^4.4.3" }, "devDependencies": { @@ -43,6 +43,6 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@modelcontextprotocol/server-everything": "^2026.7.4", "@modelcontextprotocol/server-filesystem": "^2026.7.4", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index b55896544d..7f35ae3ed3 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -13,8 +13,8 @@ * @module @deepseek-ai/dsh-mcp-client */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js' import { createTransport } from './transport.ts' diff --git a/packages/mcp/mcp-client/src/invariant.ts b/packages/mcp/mcp-client/src/invariant.ts index e2d8ac22cb..6532d8c2ef 100644 --- a/packages/mcp/mcp-client/src/invariant.ts +++ b/packages/mcp/mcp-client/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-mcp-client' diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts index 889684923b..92862fa15f 100644 --- a/packages/mcp/mcp-client/src/tools.ts +++ b/packages/mcp/mcp-client/src/tools.ts @@ -16,7 +16,7 @@ import { createHash } from 'node:crypto' import type { Client } from '@modelcontextprotocol/sdk/client/index.js' import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' import { z } from 'zod' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools' import type { JsonSchemaNode, JsonValue } from '@deepseek-ai/dsh-tools' diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index e30a1ee716..06d82eb77e 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -3,7 +3,7 @@ * Isolated file so vi.mock of the MCP SDK doesn't pollute other test suites. */ import { describe, expect, it, vi, beforeEach } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import type { Config } from '@deepseek-ai/dsh-mcp-client' diff --git a/packages/mcp/mcp-client/tests/load-path.spec.ts b/packages/mcp/mcp-client/tests/load-path.spec.ts index 5507cd5b83..89d6ce1f2a 100644 --- a/packages/mcp/mcp-client/tests/load-path.spec.ts +++ b/packages/mcp/mcp-client/tests/load-path.spec.ts @@ -11,7 +11,7 @@ */ import { describe, expect, it } from 'vitest' -import Loader from '@cordisjs/plugin-loader' +import Loader from '@deepseek-ai/cordis-plugin-loader' import * as mcpClient from '@deepseek-ai/dsh-mcp-client' describe('dsh-mcp-client real-load-path guard', () => { diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index e1f51d20e9..64f76e4a34 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -14,7 +14,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import { z } from 'zod' diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 22559d74cf..aa97ba34ce 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type JsonValue } from '@deepseek-ai/dsh-tools' diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index a810981357..d5c24e3877 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -43,7 +43,7 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-commands": { @@ -65,6 +65,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 86da4d4935..53cea83f5c 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -23,7 +23,7 @@ * @module @deepseek-ai/dsh-plan-mode */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { z as zod } from 'zod' import type { ZodType } from 'zod' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' @@ -54,7 +54,7 @@ declare module '@deepseek-ai/dsh-session/types' { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { planMode: PlanModeService } diff --git a/packages/plan/plan-mode/src/invariant.ts b/packages/plan/plan-mode/src/invariant.ts index 797010c59f..634efadfb5 100644 --- a/packages/plan/plan-mode/src/invariant.ts +++ b/packages/plan/plan-mode/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned durable plan-mode invariants. @module @deepseek-ai/dsh-plan-mode/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 34678714fa..f061f12034 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/plan/plan-mode/tests/invariant.spec.ts b/packages/plan/plan-mode/tests/invariant.spec.ts index 7ebee4cdb7..7cab457b6e 100644 --- a/packages/plan/plan-mode/tests/invariant.spec.ts +++ b/packages/plan/plan-mode/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as PlanModeInvariant from '@deepseek-ai/dsh-plan-mode/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index fddee83915..529dde6879 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools' diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index b1e546d8b7..b9557c0c7e 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -10,7 +10,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index c012dc3eda..5a08e6f99d 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -25,23 +25,23 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@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", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "js-yaml": "^4.1.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-atomic-write": "workspace:^", @@ -54,6 +54,6 @@ "@deepseek-ai/dsh-settings-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts index de8312893a..9e91bc85a2 100644 --- a/packages/preset/agent-presets/src/discovery.ts +++ b/packages/preset/agent-presets/src/discovery.ts @@ -17,7 +17,7 @@ import { readdir, readFile, stat } from 'node:fs/promises' import { join, resolve } from 'node:path' import { load } from 'js-yaml' -import { entryListSchema } from '@cordisjs/plugin-include' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { expandHomePath } from '@deepseek-ai/dsh-paths' import { readPresetMetadata } from './metadata.ts' import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts' diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 1dde902234..18c57dc17e 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -22,8 +22,8 @@ */ import { stat } from 'node:fs/promises' -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { discoverPresets } from './discovery.ts' @@ -62,7 +62,7 @@ export { resolveSessionPreset, type PresetBearingSession } from './session.ts' export { PresetMountError, UnknownPresetError } from './types.ts' export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { agentPresets: AgentPresets } diff --git a/packages/preset/agent-presets/src/invariant.ts b/packages/preset/agent-presets/src/invariant.ts index 7a08eab7f1..72716e328e 100644 --- a/packages/preset/agent-presets/src/invariant.ts +++ b/packages/preset/agent-presets/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-agent-presets/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' // Imported through the package name, not `./mount.ts`: a module shared between // the two build entry points becomes a third chunk that the published `files` diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index e968a97c25..d78603b749 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -16,9 +16,9 @@ import { isAbsolute } from 'node:path' import { pathToFileURL } from 'node:url' -import { Context, type Fiber } from 'cordis' -import { Include } from '@cordisjs/plugin-include' -import type { EntryTree } from '@cordisjs/plugin-loader' +import { Context, type Fiber } from '@deepseek-ai/cordis' +import { Include } from '@deepseek-ai/cordis-plugin-include' +import type { EntryTree } from '@deepseek-ai/cordis-plugin-loader' import { scopeOf, scopeParentOf, type ScopeKey } from '@deepseek-ai/dsh-scope' import { PresetMountError, type AgentPreset } from './types.ts' diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index b6f4ef388a..df69a792d5 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -11,9 +11,9 @@ import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import { beforeEach, describe, expect, it } from 'vitest' import AgentPresets, { COMPOSITION_FILE, copyComposition, METADATA_FILE, diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index 17d89813c3..4704d9c08e 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -1,8 +1,8 @@ import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index afe297a406..c27d0a535b 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -2,9 +2,9 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -18,7 +18,7 @@ import AgentPresets, { import type { Config } from '@deepseek-ai/dsh-agent-presets' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Published by the `isolated` fixture preset behind an entry-local realm. */ fixtureIsolatedSvc: { label: string } diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index 081ffceba4..ef75eb8b78 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -8,9 +8,9 @@ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index 5ec7678d16..cda2faf015 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -27,15 +27,15 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/preset/persona/src/index.ts b/packages/preset/persona/src/index.ts index ec56bcc780..4690bd2618 100644 --- a/packages/preset/persona/src/index.ts +++ b/packages/preset/persona/src/index.ts @@ -13,8 +13,8 @@ * @module @deepseek-ai/dsh-persona */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-system-prompt' // Imported rather than restated: the registry declares the slot this row diff --git a/packages/preset/persona/src/invariant.ts b/packages/preset/persona/src/invariant.ts index 5f9068fe24..0f818eb888 100644 --- a/packages/preset/persona/src/invariant.ts +++ b/packages/preset/persona/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-persona' diff --git a/packages/preset/persona/tests/persona.spec.ts b/packages/preset/persona/tests/persona.spec.ts index bb7555df7c..c4dbe825b0 100644 --- a/packages/preset/persona/tests/persona.spec.ts +++ b/packages/preset/persona/tests/persona.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { createScope, type ScopeKey } from '@deepseek-ai/dsh-scope' import { describe, expect, it } from 'vitest' diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json index 94f98c02d5..82705255dc 100644 --- a/packages/pty/pty-local/package.json +++ b/packages/pty/pty-local/package.json @@ -32,10 +32,10 @@ "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -46,6 +46,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/pty/pty-local/src/config.ts b/packages/pty/pty-local/src/config.ts index 41733c0959..be9ae3eed3 100644 --- a/packages/pty/pty-local/src/config.ts +++ b/packages/pty/pty-local/src/config.ts @@ -1,6 +1,6 @@ /** Validated configuration for the local PTY backend. */ -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' /** Public plugin configuration. */ export interface Config { diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts index fa2237c959..ee48a5821d 100644 --- a/packages/pty/pty-local/src/index.ts +++ b/packages/pty/pty-local/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-pty-local */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty' diff --git a/packages/pty/pty-local/src/invariant.ts b/packages/pty/pty-local/src/invariant.ts index b54ac50f63..174fab273a 100644 --- a/packages/pty/pty-local/src/invariant.ts +++ b/packages/pty/pty-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-pty-local' diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index dd3a956536..fc07fc784c 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { PassThrough } from 'node:stream' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox, type Agent } from '@deepseek-ai/dsh-agent' import SandboxProvider from '@deepseek-ai/dsh-sandbox' diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 31ffaf7e3f..f23a6f077a 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'nod import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/pty/pty/package.json b/packages/pty/pty/package.json index 702426a2b7..9671cfd8ca 100644 --- a/packages/pty/pty/package.json +++ b/packages/pty/pty/package.json @@ -28,13 +28,13 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/pty/pty/src/index.ts b/packages/pty/pty/src/index.ts index 8ff4168e1a..fb28107238 100644 --- a/packages/pty/pty/src/index.ts +++ b/packages/pty/pty/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-pty */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { PtyBackendCleanupError } from './types.ts' import type { @@ -45,7 +45,7 @@ export { PtyBackendCleanupError } from './types.ts' /** Opaque identity minted by {@link PtyService} for one live PTY session. */ export type PtySessionId = PtySessionIdValue -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { pty: PtyService } diff --git a/packages/pty/pty/src/invariant.ts b/packages/pty/pty/src/invariant.ts index 9395d2164c..ae8b0110f0 100644 --- a/packages/pty/pty/src/invariant.ts +++ b/packages/pty/pty/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-pty' diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 0301de837a..02dc004ae3 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/pty/tool-bash-persistent/package.json b/packages/pty/tool-bash-persistent/package.json index 23aad9f32e..fdd5688e8e 100644 --- a/packages/pty/tool-bash-persistent/package.json +++ b/packages/pty/tool-bash-persistent/package.json @@ -29,14 +29,14 @@ "@deepseek-ai/dsh-pty": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -49,6 +49,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 24cc998cf2..fa2a965231 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -4,8 +4,8 @@ */ import { randomUUID } from 'node:crypto' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type { PtyReadResult, PtySendResult, PtySessionId } from '@deepseek-ai/dsh-pty' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' diff --git a/packages/pty/tool-bash-persistent/src/invariant.ts b/packages/pty/tool-bash-persistent/src/invariant.ts index 5e276d4c45..b06c59764c 100644 --- a/packages/pty/tool-bash-persistent/src/invariant.ts +++ b/packages/pty/tool-bash-persistent/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash-persistent' diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts index a69048e8d6..90cc6b7422 100644 --- a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 28a950eacf..d43fd64399 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' diff --git a/packages/pty/tool-pty/package.json b/packages/pty/tool-pty/package.json index f443bbbdf4..f3afefdc0a 100644 --- a/packages/pty/tool-pty/package.json +++ b/packages/pty/tool-pty/package.json @@ -25,7 +25,7 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", @@ -36,11 +36,11 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -56,6 +56,6 @@ "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/pty/tool-pty/src/index.ts b/packages/pty/tool-pty/src/index.ts index fc66d2646e..f6dc2fc42c 100644 --- a/packages/pty/tool-pty/src/index.ts +++ b/packages/pty/tool-pty/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-tool-pty */ -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { PtySessionId } from '@deepseek-ai/dsh-pty' diff --git a/packages/pty/tool-pty/src/invariant.ts b/packages/pty/tool-pty/src/invariant.ts index f8451af962..05c6fbf9d9 100644 --- a/packages/pty/tool-pty/src/invariant.ts +++ b/packages/pty/tool-pty/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pty' diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index 35d0ea5d0a..ff19707d1e 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index 235aad0e02..042c620b29 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 226c241204..5af6264b4a 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -29,18 +29,18 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^", "@deepseek-ai/node-addon-landlock-run": "workspace:*", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index fc19a8dbea..7288517213 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -30,8 +30,8 @@ import { launcherPath as landlockLauncherPath, probe as defaultProbeLandlock, } from '@deepseek-ai/node-addon-landlock-run' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' diff --git a/packages/sandbox/sandbox-local/src/invariant.ts b/packages/sandbox/sandbox-local/src/invariant.ts index e990d46acc..e4f0891631 100644 --- a/packages/sandbox/sandbox-local/src/invariant.ts +++ b/packages/sandbox/sandbox-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-local' diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index ca2410c317..3bee8f4b4d 100644 --- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -11,7 +11,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SessionId } from '@deepseek-ai/dsh-session' import { LocalSandboxProvider, sessionTempDir } from '@deepseek-ai/dsh-sandbox-local' diff --git a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts index 9a093e4632..3da6787049 100644 --- a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { bwrapProfileArgs } from '../src/profiles.ts' diff --git a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts index ff4947a4ca..4bee858b78 100644 --- a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 2edde27ca7..9ad6f5ed9e 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -11,7 +11,7 @@ import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 4b95f4da58..6b7e547099 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -7,11 +7,11 @@ import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' /** - * Keyless publish-path rehearsal. It packs the provider, its workspace peers, and the current - * repository's Landlock entry/platform packages, then installs those exact tarballs in an external - * plain-Node consumer. The host launcher comes from the exact local tarballs, so no registry copy, - * tsx, path mapping, or workspace resolution can hide missing files, dependency errors, or lost - * executable modes. npm may still query registry metadata for an incompatible optional platform + * Keyless publish-path rehearsal. It packs the provider, its workspace peers, the vendored framework + * peer, and the current repository's Landlock entry/platform packages, then installs those exact + * tarballs in an external plain-Node consumer. The host launcher comes from the exact local tarballs, + * so no registry copy, tsx, path mapping, or workspace resolution can hide missing files, dependency + * errors, or lost executable modes. npm may still query registry metadata for an incompatible optional platform * package that cannot supply the host launcher. * * The installed launcher must match the host architecture, remain executable, and either confine a @@ -38,6 +38,13 @@ const WORKSPACE_CLOSURE = [ 'packages/util/brand', 'packages/util/timeout', 'packages/support/invariants', + // The framework and the vendored packages the closure declares outright: + // rescoped into @deepseek-ai, so the consumer installs this repository's + // copies. Schemastery is a hard dependency of three members above, not a + // peer, so npm resolves it while installing them. + 'vendor/cordis', + 'vendor/cosmokit', + 'vendor/schemastery', ] /** ELF `e_machine` (offset 18, LE) for this host: x86-64 = 62, AArch64 = 183. */ @@ -96,10 +103,10 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- } tarballs.push(...nativeTarballs) - // Peer ranges resolve to the tarballs; Cordis is pinned to their peer range. Do not omit optional + // Peer ranges resolve to the tarballs, the framework peer included. Do not omit optional // dependencies because the launcher selects its OS/CPU package through one. writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' })) - const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.7'], { + const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs], { cwd: consumerDir, encoding: 'utf8', timeout: 300_000, @@ -113,7 +120,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- writeFileSync(join(consumerDir, 'consumer.mjs'), ` import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' - import { Context } from 'cordis' + import { Context } from '@deepseek-ai/cordis' import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' const ctx = new Context() diff --git a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts index 6d645b1a3b..4ff399dc67 100644 --- a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { seatbeltProfileArgs } from '../src/profiles.ts' diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index 6124a75ff6..7636977d6e 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -30,10 +30,10 @@ "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 3a3aac1d70..eee5a43669 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -19,8 +19,8 @@ */ import { resolve as resolvePath } from 'node:path' -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent' import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session } from '@deepseek-ai/dsh-session' @@ -51,7 +51,7 @@ function renderPolicyContext(policy: SandboxExecutionPolicy): string { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sandboxPolicy: SandboxPolicyService } diff --git a/packages/sandbox/sandbox-policy/src/invariant.ts b/packages/sandbox/sandbox-policy/src/invariant.ts index 20fd176af6..32e2c998b7 100644 --- a/packages/sandbox/sandbox-policy/src/invariant.ts +++ b/packages/sandbox/sandbox-policy/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned session-event invariants for sandbox policy. @module @deepseek-ai/dsh-sandbox-policy/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { SANDBOX_MODES } from './session-mode.ts' diff --git a/packages/sandbox/sandbox-policy/tests/invariant.spec.ts b/packages/sandbox/sandbox-policy/tests/invariant.spec.ts index d3255b305e..5ed924273a 100644 --- a/packages/sandbox/sandbox-policy/tests/invariant.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' import * as SandboxPolicyInvariant from '@deepseek-ai/dsh-sandbox-policy/invariant' diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 11552f2a3a..0a239114aa 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -8,7 +8,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node: import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 2f13b71296..8bfb1f3d80 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -31,7 +31,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "koffi": "^3.1.0" @@ -40,6 +40,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/sandbox/sandbox-windows-acl/src/invariant.ts b/packages/sandbox/sandbox-windows-acl/src/invariant.ts index 35ea265a4b..95b0555d49 100644 --- a/packages/sandbox/sandbox-windows-acl/src/invariant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-windows-acl' diff --git a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts index 2557b1f43b..4c3db08786 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 7c91afe76b..558588cfa7 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -28,12 +28,12 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 5fa3cc991b..8f69f90bbf 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-sandbox */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -143,7 +143,7 @@ export class SandboxUnavailableError extends HarnessError { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sandbox: SandboxProvider } diff --git a/packages/sandbox/sandbox/src/invariant.ts b/packages/sandbox/sandbox/src/invariant.ts index 7ee5be733f..4d7f6dcdf0 100644 --- a/packages/sandbox/sandbox/src/invariant.ts +++ b/packages/sandbox/sandbox/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox' diff --git a/packages/scaffold/client/package.json b/packages/scaffold/client/package.json index 3dbb236ab3..7e0a4b0540 100644 --- a/packages/scaffold/client/package.json +++ b/packages/scaffold/client/package.json @@ -28,13 +28,13 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-sdk-protocol": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/scaffold/client/src/invariant.ts b/packages/scaffold/client/src/invariant.ts index db40e4e005..b93254ee9f 100644 --- a/packages/scaffold/client/src/invariant.ts +++ b/packages/scaffold/client/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-client' diff --git a/packages/scaffold/create-sdk/package.json b/packages/scaffold/create-sdk/package.json index 262d375563..4b40b6b2ca 100644 --- a/packages/scaffold/create-sdk/package.json +++ b/packages/scaffold/create-sdk/package.json @@ -33,10 +33,10 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/scaffold/create-sdk/src/invariant.ts b/packages/scaffold/create-sdk/src/invariant.ts index 368b46fc70..a4de619111 100644 --- a/packages/scaffold/create-sdk/src/invariant.ts +++ b/packages/scaffold/create-sdk/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/create-sdk' diff --git a/packages/scaffold/create-sdk/tests/link-workspace.e2e.ts b/packages/scaffold/create-sdk/tests/link-workspace.e2e.ts index b978c8217e..0e995202fd 100644 --- a/packages/scaffold/create-sdk/tests/link-workspace.e2e.ts +++ b/packages/scaffold/create-sdk/tests/link-workspace.e2e.ts @@ -73,7 +73,7 @@ describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', () }) await writeFile(join(root, 'plugins/probe/src/index.ts'), ` import { writeFileSync } from 'node:fs' - import type { Context } from 'cordis' + import type { Context } from '@deepseek-ai/cordis' export const name = 'probe' export function apply(_ctx: Context): void { writeFileSync(new URL('../../../plugin-loaded', import.meta.url), 'loaded\\n') @@ -119,7 +119,7 @@ describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', () const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { dependencies: Record<string, string> } - expect(manifest.dependencies.cordis).toMatch(name === 'npm' ? /^file:/ : name === 'pnpm' ? /^link:/ : /^portal:/) + expect(manifest.dependencies['@deepseek-ai/cordis']).toMatch(name === 'npm' ? /^file:/ : name === 'pnpm' ? /^link:/ : /^portal:/) expect(manifest.dependencies).not.toHaveProperty('node-addon-require-builtin') }, 180_000) } diff --git a/packages/scaffold/helper/package.json b/packages/scaffold/helper/package.json index bed70dbbda..f65b51052a 100644 --- a/packages/scaffold/helper/package.json +++ b/packages/scaffold/helper/package.json @@ -34,7 +34,7 @@ "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -47,6 +47,6 @@ "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/scaffold/helper/src/features/builtin/index.ts b/packages/scaffold/helper/src/features/builtin/index.ts index abdcc35dd5..38246e6cf2 100644 --- a/packages/scaffold/helper/src/features/builtin/index.ts +++ b/packages/scaffold/helper/src/features/builtin/index.ts @@ -101,7 +101,7 @@ config: id: 'default', label: 'Cordis HMR', default: true, - resources: [{ kind: 'npm-cordis-config-entry', id: 'hmr', package: '@cordisjs/plugin-hmr' }], + resources: [{ kind: 'npm-cordis-config-entry', id: 'hmr', package: '@deepseek-ai/cordis-plugin-hmr' }], }], }, { diff --git a/packages/scaffold/helper/src/features/builtin/spine.ts b/packages/scaffold/helper/src/features/builtin/spine.ts index 3e1acb98fb..26f0d03232 100644 --- a/packages/scaffold/helper/src/features/builtin/spine.ts +++ b/packages/scaffold/helper/src/features/builtin/spine.ts @@ -26,7 +26,7 @@ class SpineOption extends FeatureOption { override contribution(_profile: ProjectProfile): ProjectContribution { return new ProjectContribution([ - ...npmCordisConfigEntry(ID, { id: 'timer', name: '@cordisjs/plugin-timer' }), + ...npmCordisConfigEntry(ID, { id: 'timer', name: '@deepseek-ai/cordis-plugin-timer' }), ...npmCordisConfigEntry(ID, { id: 'llm', name: '@deepseek-ai/dsh-llm' }), ...npmCordisConfigEntry(ID, { id: 'session', name: '@deepseek-ai/dsh-session' }), ...npmCordisConfigEntry(ID, { diff --git a/packages/scaffold/helper/src/invariant.ts b/packages/scaffold/helper/src/invariant.ts index 9185ac8867..dfebbb2006 100644 --- a/packages/scaffold/helper/src/invariant.ts +++ b/packages/scaffold/helper/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-helper' diff --git a/packages/scaffold/helper/src/package-managers/link-workspace.ts b/packages/scaffold/helper/src/package-managers/link-workspace.ts index 3fbc063451..4d185ce4d1 100644 --- a/packages/scaffold/helper/src/package-managers/link-workspace.ts +++ b/packages/scaffold/helper/src/package-managers/link-workspace.ts @@ -73,7 +73,7 @@ export class LinkWorkspace { if (packages.has(manifest.name)) throw new Error(`duplicate linked package name: ${manifest.name}`) packages.set(manifest.name, { directory, manifest }) } - if (!packages.has('cordis') || !packages.has('@deepseek-ai/dsh-scripts')) { + if (!packages.has('@deepseek-ai/cordis') || !packages.has('@deepseek-ai/dsh-scripts')) { throw new Error(`not a DeepSeek Harness repository root: ${absolute}`) } return new LinkWorkspace(absolute, packages) diff --git a/packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts b/packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts index 616d0cedd8..da978e845c 100644 --- a/packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts +++ b/packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts @@ -83,7 +83,7 @@ export class LocalPluginBlueprint { documents(projectName: string, releaseVersion: string): TextProjectFile[] { const name = this.packageName(projectName) const toolName = this.name.replaceAll('-', '_') - const cordisSpec = resolveNpmDependency('cordis', 'devDependencies', releaseVersion).spec + const cordisSpec = resolveNpmDependency('@deepseek-ai/cordis', 'devDependencies', releaseVersion).spec const manifest = { name, version: '0.0.0', @@ -94,10 +94,10 @@ export class LocalPluginBlueprint { exports: { '.': { types: './lib/index.d.ts', default: './lib/index.js' } }, peerDependencies: { ...this.kind === 'tool' ? { '@deepseek-ai/dsh-tools': `^${releaseVersion}` } : {}, - cordis: cordisSpec, + '@deepseek-ai/cordis': cordisSpec, }, devDependencies: { - cordis: cordisSpec, + '@deepseek-ai/cordis': cordisSpec, }, } const tsconfig = { diff --git a/packages/scaffold/helper/src/project/npm-dependency-policy.ts b/packages/scaffold/helper/src/project/npm-dependency-policy.ts index 727bd6648a..a876973a29 100644 --- a/packages/scaffold/helper/src/project/npm-dependency-policy.ts +++ b/packages/scaffold/helper/src/project/npm-dependency-policy.ts @@ -19,17 +19,17 @@ export interface BaselineNpmDependencies { } const EXTERNAL_NPM_DEPENDENCY_SPECS: Readonly<Record<string, string>> = { - '@cordisjs/plugin-hmr': '^1.0.15', - '@cordisjs/plugin-timer': '^1.1.2', + '@deepseek-ai/cordis-plugin-hmr': '^1.0.15', + '@deepseek-ai/cordis-plugin-timer': '^1.1.2', '@types/node': '^22.20.0', - cordis: '^4.0.0-rc.7', + '@deepseek-ai/cordis': '^4.0.0-rc.7', tsdown: '0.22.2', tsx: '^4.22.4', typescript: '^6.0.3', } const BASELINE_NPM_DEPENDENCY_NAMES: Readonly<Record<NpmDependencySection, readonly string[]>> = { - dependencies: ['@deepseek-ai/dsh-scripts', 'cordis'], + dependencies: ['@deepseek-ai/dsh-scripts', '@deepseek-ai/cordis'], devDependencies: ['@types/node', 'tsdown', 'tsx', 'typescript'], } diff --git a/packages/scaffold/helper/src/templates/assets/local-plugin.ts.tpl b/packages/scaffold/helper/src/templates/assets/local-plugin.ts.tpl index 2f15bdef91..76b12769bb 100644 --- a/packages/scaffold/helper/src/templates/assets/local-plugin.ts.tpl +++ b/packages/scaffold/helper/src/templates/assets/local-plugin.ts.tpl @@ -1,5 +1,5 @@ /** Local Cordis plugin. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = '{{pluginName}}' diff --git a/packages/scaffold/helper/src/templates/assets/local-tool.ts.tpl b/packages/scaffold/helper/src/templates/assets/local-tool.ts.tpl index 4333d41c70..a48a73375c 100644 --- a/packages/scaffold/helper/src/templates/assets/local-tool.ts.tpl +++ b/packages/scaffold/helper/src/templates/assets/local-tool.ts.tpl @@ -1,5 +1,5 @@ /** Project-local model-facing tool. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = '{{pluginName}}' diff --git a/packages/scaffold/helper/tests/documents.spec.ts b/packages/scaffold/helper/tests/documents.spec.ts index 317a9ed3ab..12e97dc6c0 100644 --- a/packages/scaffold/helper/tests/documents.spec.ts +++ b/packages/scaffold/helper/tests/documents.spec.ts @@ -279,10 +279,10 @@ describe('package manager strategies', () => { expect(inferPackageManagerName(undefined, 'unknown/1')).toBeUndefined() expect(inferPackageManagerName('yarn', undefined)).toBe('yarn') expect(() => createPackageManager('npm', 'invalid')).toThrow('invalid package manager version') - expect(resolveNpmDependency('cordis', 'devDependencies', '0.0.1')).toEqual({ + expect(resolveNpmDependency('@deepseek-ai/cordis', 'devDependencies', '0.0.1')).toEqual({ section: 'devDependencies', spec: '^4.0.0-rc.7', }) - expect(resolveNpmDependency('@cordisjs/plugin-hmr', 'dependencies', '0.0.1').spec).toBe('^1.0.15') + expect(resolveNpmDependency('@deepseek-ai/cordis-plugin-hmr', 'dependencies', '0.0.1').spec).toBe('^1.0.15') expect(resolveNpmDependency('tsdown', 'devDependencies', '0.0.1').spec).toBe('0.22.2') expect(resolveNpmDependency('@deepseek-ai/dsh-tools', 'dependencies', '1.2.3').spec).toBe('^1.2.3') expect(() => resolveNpmDependency('unknown', 'dependencies', '0.0.1')).toThrow('no generated-project') @@ -353,23 +353,23 @@ describe('package manager strategies', () => { await mkdir(join(root, 'vendor', 'cordis'), { recursive: true }) await mkdir(join(root, 'packages', 'sdk', 'scripts'), { recursive: true }) await mkdir(join(root, 'packages', 'sdk', 'helper'), { recursive: true }) - await writeFile(join(root, 'vendor', 'cordis', 'package.json'), JSON.stringify({ name: 'cordis' })) + await writeFile(join(root, 'vendor', 'cordis', 'package.json'), JSON.stringify({ name: '@deepseek-ai/cordis' })) await writeFile(join(root, 'packages', 'sdk', 'helper', 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-helper' })) await writeFile(join(root, 'packages', 'sdk', 'scripts', 'package.json'), JSON.stringify({ - name: '@deepseek-ai/dsh-scripts', dependencies: { '@deepseek-ai/dsh-helper': '^0.0.1' }, peerDependencies: { cordis: '^4' }, + name: '@deepseek-ai/dsh-scripts', dependencies: { '@deepseek-ai/dsh-helper': '^0.0.1' }, peerDependencies: { '@deepseek-ai/cordis': '^4' }, })) const workspace = await LinkWorkspace.open(root) expect(workspace.closure(['@deepseek-ai/dsh-scripts'])).toEqual([ - '@deepseek-ai/dsh-helper', '@deepseek-ai/dsh-scripts', 'cordis', + '@deepseek-ai/cordis', '@deepseek-ai/dsh-helper', '@deepseek-ai/dsh-scripts', ]) const manifest = PackageJsonFile.create('{"name":"consumer","description":"test"}') manifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1') const pnpmWorkspace = PnpmWorkspaceFile.create() workspace.apply(join(root, 'consumer'), manifest, new PnpmPackageManager('10.0.0'), [pnpmWorkspace]) - expect(manifest.npmDependency('cordis')?.spec).toMatch(/^link:/) + expect(manifest.npmDependency('@deepseek-ai/cordis')?.spec).toMatch(/^link:/) expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false') - expect(workspace.packageDirectory('cordis')).toBe(join(root, 'vendor', 'cordis')) - expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('cordis') + expect(workspace.packageDirectory('@deepseek-ai/cordis')).toBe(join(root, 'vendor', 'cordis')) + expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('@deepseek-ai/cordis') expect(workspace.packageDirectory('missing')).toBeUndefined() // A generated workspace member resolves its own dependencies: every local name it // declares relinks, while a peer keeps the range package managers require there. diff --git a/packages/scaffold/helper/tests/project.spec.ts b/packages/scaffold/helper/tests/project.spec.ts index 01918d7570..b803e658d7 100644 --- a/packages/scaffold/helper/tests/project.spec.ts +++ b/packages/scaffold/helper/tests/project.spec.ts @@ -187,12 +187,12 @@ describe('SdkProject and ProjectEditSession', () => { expect(project.cordis.entry('scope-invariant')?.name).toBe('@deepseek-ai/dsh-scope/invariant') expect(project.cordis.entry('agent-loop-invariant')?.name).toBe('@deepseek-ai/dsh-agent-loop/invariant') expect(project.cordis.entry('system-prompt')?.config?.persona).toContain('{{cwd}}') - expect(project.packageManifest().dependencies?.['@cordisjs/plugin-timer']).toBe('^1.1.2') - expect(project.packageManifest().dependencies?.['@cordisjs/plugin-hmr']).toBe('^1.0.15') + expect(project.packageManifest().dependencies?.['@deepseek-ai/cordis-plugin-timer']).toBe('^1.1.2') + expect(project.packageManifest().dependencies?.['@deepseek-ai/cordis-plugin-hmr']).toBe('^1.0.15') expect(project.packageManifest().dependencies?.['@deepseek-ai/dsh-scope']).toBe('^0.0.1') expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-scope/invariant') expect(project.packageManifest().dependencies).not.toHaveProperty('node-addon-require-builtin') - expect(project.cordis.entry('hmr')).toMatchObject({ name: '@cordisjs/plugin-hmr' }) + expect(project.cordis.entry('hmr')).toMatchObject({ name: '@deepseek-ai/cordis-plugin-hmr' }) expect(project.cordis.entry('llm-deepseek')).not.toHaveProperty('config.apiKey') expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('baseURL') expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('models') @@ -502,7 +502,7 @@ describe('SdkProject and ProjectEditSession', () => { internals.applyResource(transient, undefined) internals.removeResource(transient) internals.replaceContribution( - new ProjectContribution([{ kind: 'npm-dependency', key: resourceKey('shared'), name: 'cordis', section: 'dependencies' }]), + new ProjectContribution([{ kind: 'npm-dependency', key: resourceKey('shared'), name: '@deepseek-ai/cordis', section: 'dependencies' }]), new ProjectContribution([{ kind: 'cordis-config-entry', key: resourceKey('shared'), entry: { id: 'new', name: 'new' }, ownedConfigKeys: [], }]), diff --git a/packages/scaffold/protocol/package.json b/packages/scaffold/protocol/package.json index fe7d057c11..c0d6ff1046 100644 --- a/packages/scaffold/protocol/package.json +++ b/packages/scaffold/protocol/package.json @@ -28,13 +28,13 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/scaffold/protocol/src/invariant.ts b/packages/scaffold/protocol/src/invariant.ts index c1f0b45f2d..948fb16d13 100644 --- a/packages/scaffold/protocol/src/invariant.ts +++ b/packages/scaffold/protocol/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-protocol' diff --git a/packages/scaffold/scripts/package.json b/packages/scaffold/scripts/package.json index deef54831f..d6ef776d67 100644 --- a/packages/scaffold/scripts/package.json +++ b/packages/scaffold/scripts/package.json @@ -42,7 +42,7 @@ "peerDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "tsdown": "^0.22.2", "tsx": "^4.22.4" }, @@ -57,7 +57,7 @@ "devDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "tsdown": "^0.22.2", "tsx": "^4.22.4" } diff --git a/packages/scaffold/scripts/src/invariant.ts b/packages/scaffold/scripts/src/invariant.ts index 72e97f3628..b43a6d1cf3 100644 --- a/packages/scaffold/scripts/src/invariant.ts +++ b/packages/scaffold/scripts/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-scripts' diff --git a/packages/scaffold/scripts/src/runtime.ts b/packages/scaffold/scripts/src/runtime.ts index 426ac90e5b..b0d4c8c5ad 100644 --- a/packages/scaffold/scripts/src/runtime.ts +++ b/packages/scaffold/scripts/src/runtime.ts @@ -8,7 +8,7 @@ import { register as registerHook } from 'node:module' import { access, readFile, readdir } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { parseSdkBootArgs } from './args.ts' diff --git a/packages/scaffold/scripts/tests/scripts.spec.ts b/packages/scaffold/scripts/tests/scripts.spec.ts index 6d2ea27991..a1a4a07517 100644 --- a/packages/scaffold/scripts/tests/scripts.spec.ts +++ b/packages/scaffold/scripts/tests/scripts.spec.ts @@ -323,7 +323,7 @@ describe('build profiles and invocation', () => { await writeFile(join(root, 'cordis.yml'), '[]\n') const byUrl = await startSDK(pathToFileURL(join(root, 'cordis.yml'))) await byUrl.fiber.dispose() - const byRun = await runSDK(undefined, { cwd: root }) as import('cordis').Context + const byRun = await runSDK(undefined, { cwd: root }) as import('@deepseek-ai/cordis').Context await byRun.fiber.dispose() const dev = await startSDK('./cordis.yml', { cwd: root, dev: true }) await dev.fiber.dispose() diff --git a/packages/scaffold/server/package.json b/packages/scaffold/server/package.json index 573f724ae0..457a375b04 100644 --- a/packages/scaffold/server/package.json +++ b/packages/scaffold/server/package.json @@ -25,7 +25,7 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.17.0" + "@deepseek-ai/schemastery": "^3.17.0" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", @@ -36,10 +36,10 @@ "@deepseek-ai/dsh-sdk-protocol": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -50,6 +50,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/scaffold/server/src/index.ts b/packages/scaffold/server/src/index.ts index be7252007a..70f24dfaee 100644 --- a/packages/scaffold/server/src/index.ts +++ b/packages/scaffold/server/src/index.ts @@ -13,9 +13,9 @@ * @module @deepseek-ai/dsh-jsonrpc */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Readable, Writable } from 'node:stream' -import Schema from 'schemastery' +import Schema from '@deepseek-ai/schemastery' import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' import { HarnessSdkServer } from './server.ts' diff --git a/packages/scaffold/server/src/invariant.ts b/packages/scaffold/server/src/invariant.ts index 1a3c9b053b..f59a90188b 100644 --- a/packages/scaffold/server/src/invariant.ts +++ b/packages/scaffold/server/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc' diff --git a/packages/scaffold/server/src/server.ts b/packages/scaffold/server/src/server.ts index e6c22d7ff2..da5f2e24c4 100644 --- a/packages/scaffold/server/src/server.ts +++ b/packages/scaffold/server/src/server.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-jsonrpc/server */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { resolve } from 'node:path' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/scaffold/server/tests/plugin-apply.spec.ts b/packages/scaffold/server/tests/plugin-apply.spec.ts index b2109b8c51..1e5b9b3212 100644 --- a/packages/scaffold/server/tests/plugin-apply.spec.ts +++ b/packages/scaffold/server/tests/plugin-apply.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { PassThrough, Writable } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import * as jsonrpc from '../src/index.ts' diff --git a/packages/scaffold/server/tests/plugin-shape.spec.ts b/packages/scaffold/server/tests/plugin-shape.spec.ts index 97afa7d3ed..f268e4c24c 100644 --- a/packages/scaffold/server/tests/plugin-shape.spec.ts +++ b/packages/scaffold/server/tests/plugin-shape.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import Loader from '@cordisjs/plugin-loader' +import Loader from '@deepseek-ai/cordis-plugin-loader' import * as jsonrpc from '../src/index.ts' /** diff --git a/packages/scaffold/server/tests/server.spec.ts b/packages/scaffold/server/tests/server.spec.ts index 714fc0ada3..6cd1c6ed99 100644 --- a/packages/scaffold/server/tests/server.spec.ts +++ b/packages/scaffold/server/tests/server.spec.ts @@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/scaffold/telemetry/package.json b/packages/scaffold/telemetry/package.json index 6fe62e35fc..3ebd894a99 100644 --- a/packages/scaffold/telemetry/package.json +++ b/packages/scaffold/telemetry/package.json @@ -31,12 +31,12 @@ "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/scaffold/telemetry/src/invariant.ts b/packages/scaffold/telemetry/src/invariant.ts index c3676a1384..bee683c50e 100644 --- a/packages/scaffold/telemetry/src/invariant.ts +++ b/packages/scaffold/telemetry/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-telemetry' diff --git a/packages/self-modification/tool-cordis/package.json b/packages/self-modification/tool-cordis/package.json index 0b1c78cf8f..5544c84b49 100644 --- a/packages/self-modification/tool-cordis/package.json +++ b/packages/self-modification/tool-cordis/package.json @@ -28,14 +28,14 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -45,6 +45,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/self-modification/tool-cordis/src/fiber-state.ts b/packages/self-modification/tool-cordis/src/fiber-state.ts index dcd9da149b..2c9dbfd240 100644 --- a/packages/self-modification/tool-cordis/src/fiber-state.ts +++ b/packages/self-modification/tool-cordis/src/fiber-state.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-tool-cordis/fiber-state */ -import type { FiberState as FiberStateEnum } from 'cordis' +import type { FiberState as FiberStateEnum } from '@deepseek-ai/cordis' /** Value mirror of the cordis `FiberState` const enum (see the module doc for why a mirror exists). */ export const FiberState = { diff --git a/packages/self-modification/tool-cordis/src/guard.ts b/packages/self-modification/tool-cordis/src/guard.ts index 22d85936f4..7724e339be 100644 --- a/packages/self-modification/tool-cordis/src/guard.ts +++ b/packages/self-modification/tool-cordis/src/guard.ts @@ -12,8 +12,8 @@ * @module @deepseek-ai/dsh-tool-cordis/guard */ -import { Context } from 'cordis' -import type { Plugin } from 'cordis' +import { Context } from '@deepseek-ai/cordis' +import type { Plugin } from '@deepseek-ai/cordis' import { scopeOf } from '@deepseek-ai/dsh-scope' import { assertSupportedJsonSchema, defineTool } from '@deepseek-ai/dsh-tools' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' diff --git a/packages/self-modification/tool-cordis/src/index.ts b/packages/self-modification/tool-cordis/src/index.ts index 46c51fa6ad..272ed800a0 100644 --- a/packages/self-modification/tool-cordis/src/index.ts +++ b/packages/self-modification/tool-cordis/src/index.ts @@ -7,8 +7,8 @@ * @module @deepseek-ai/dsh-tool-cordis */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import { STATE_LABELS } from './fiber-state.ts' import { isPlugin, pluginName } from './guard.ts' diff --git a/packages/self-modification/tool-cordis/src/inspect.ts b/packages/self-modification/tool-cordis/src/inspect.ts index 08bbeb44cf..c787be72d2 100644 --- a/packages/self-modification/tool-cordis/src/inspect.ts +++ b/packages/self-modification/tool-cordis/src/inspect.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-tool-cordis/inspect */ -import type { Context, Fiber } from 'cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts' import type { EventApiEntry, InheritedApiEntry, ServiceApiEntry, TypeApiEntry } from './api-catalog.ts' diff --git a/packages/self-modification/tool-cordis/src/invariant.ts b/packages/self-modification/tool-cordis/src/invariant.ts index 6fd73d0353..3b58f40fd7 100644 --- a/packages/self-modification/tool-cordis/src/invariant.ts +++ b/packages/self-modification/tool-cordis/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-cordis' diff --git a/packages/self-modification/tool-cordis/src/mount.ts b/packages/self-modification/tool-cordis/src/mount.ts index 942d45eb79..45e0a429fb 100644 --- a/packages/self-modification/tool-cordis/src/mount.ts +++ b/packages/self-modification/tool-cordis/src/mount.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-tool-cordis/mount */ -import type { Context, Fiber, Plugin } from 'cordis' +import type { Context, Fiber, Plugin } from '@deepseek-ai/cordis' import { guardedPlugin } from './guard.ts' /** One tracked dynamic mount: the fiber plus the display name captured at mount time. */ diff --git a/packages/self-modification/tool-cordis/tests/cordis-lifecycle.spec.ts b/packages/self-modification/tool-cordis/tests/cordis-lifecycle.spec.ts index b290ae998e..e5bba585a7 100644 --- a/packages/self-modification/tool-cordis/tests/cordis-lifecycle.spec.ts +++ b/packages/self-modification/tool-cordis/tests/cordis-lifecycle.spec.ts @@ -1,4 +1,4 @@ -import { Context, CordisError, FiberState, type Fiber } from 'cordis' +import { Context, CordisError, FiberState, type Fiber } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' /** diff --git a/packages/self-modification/tool-cordis/tests/helpers.ts b/packages/self-modification/tool-cordis/tests/helpers.ts index b049c6c1fa..e608e4633c 100644 --- a/packages/self-modification/tool-cordis/tests/helpers.ts +++ b/packages/self-modification/tool-cordis/tests/helpers.ts @@ -1,5 +1,5 @@ -import { Context } from 'cordis' -import Timer from '@cordisjs/plugin-timer' +import { Context } from '@deepseek-ai/cordis' +import Timer from '@deepseek-ai/cordis-plugin-timer' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' diff --git a/packages/self-modification/tool-cordis/tests/inspect.spec.ts b/packages/self-modification/tool-cordis/tests/inspect.spec.ts index ca9fb4e03f..42415619e1 100644 --- a/packages/self-modification/tool-cordis/tests/inspect.spec.ts +++ b/packages/self-modification/tool-cordis/tests/inspect.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Context, Fiber } from 'cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' import { FiberState } from '../src/fiber-state.ts' import { describeApi, describeEvents, describePlugins, describeServices } from '../src/inspect.ts' import { call, LISTENER_CODE, setup, text } from './helpers.ts' diff --git a/packages/self-modification/tool-cordis/tests/integration.spec.ts b/packages/self-modification/tool-cordis/tests/integration.spec.ts index 488ab996b3..3697ea1b2b 100644 --- a/packages/self-modification/tool-cordis/tests/integration.spec.ts +++ b/packages/self-modification/tool-cordis/tests/integration.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/self-modification/tool-cordis/tests/tool-cordis.spec.ts b/packages/self-modification/tool-cordis/tests/tool-cordis.spec.ts index 32846e35dd..030260d3c8 100644 --- a/packages/self-modification/tool-cordis/tests/tool-cordis.spec.ts +++ b/packages/self-modification/tool-cordis/tests/tool-cordis.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import Loader from '@cordisjs/plugin-loader' +import Loader from '@deepseek-ai/cordis-plugin-loader' import * as tool from '../src/index.ts' import { setup } from './helpers.ts' diff --git a/packages/self-modification/tool-cordis/tests/unmount-hmr.spec.ts b/packages/self-modification/tool-cordis/tests/unmount-hmr.spec.ts index 42c4c0196c..062da430e5 100644 --- a/packages/self-modification/tool-cordis/tests/unmount-hmr.spec.ts +++ b/packages/self-modification/tool-cordis/tests/unmount-hmr.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import * as tool from '../src/index.ts' diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 4813b76b10..d2ca6da01a 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -29,7 +29,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-session-query": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-session-persistence": { @@ -37,15 +37,15 @@ } }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index dc67665f77..473146168b 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -6,8 +6,8 @@ import { createHash, randomUUID } from 'node:crypto' import type { DatabaseSync } from 'node:sqlite' -import { Context, Service, type Fiber } from 'cordis' -import z from 'schemastery' +import { Context, Service, type Fiber } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' import type { @@ -65,7 +65,7 @@ export { /** Boot-context slot for a launcher-owned absolute path to this process's derived query index. */ export const SESSION_QUERY_SQLITE_PATH_KEY = 'launcherSessionQueryPath' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Launcher-owned absolute path to this process's disposable derived query index. */ launcherSessionQueryPath?: string diff --git a/packages/session-query/session-query-sqlite/src/invariant.ts b/packages/session-query/session-query-sqlite/src/invariant.ts index 011b121eaf..6d9761807a 100644 --- a/packages/session-query/session-query-sqlite/src/invariant.ts +++ b/packages/session-query/session-query-sqlite/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-query-sqlite' diff --git a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts index 01bfa60c43..aad427f10f 100644 --- a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts +++ b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts @@ -6,8 +6,8 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' */ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import SessionStore from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 8427ebedc4..bbba91453d 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -1,6 +1,6 @@ import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context, type Fiber } from 'cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import { DatabaseSync } from 'node:sqlite' import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 3a52808f02..30e6a52593 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -31,7 +31,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-title": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-session-persistence": { @@ -45,6 +45,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 8711597b69..717b2b5190 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -1,6 +1,6 @@ /** Live/persisted logical-corpus resolution for session-query. */ -import type { Context, Fiber } from 'cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import SessionPersistence, { SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence' import type { SessionRecord } from './types.ts' diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 919bf00c88..c9cdc62221 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-session-query */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { Session, snapshotSessionEvent, type SessionId } from '@deepseek-ai/dsh-session' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title' @@ -65,7 +65,7 @@ export { } from './filters.ts' export { assertSessionHeadersCompatible } from './sources.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sessionQuery: SessionQueryService } diff --git a/packages/session-query/session-query/src/invariant.ts b/packages/session-query/session-query/src/invariant.ts index d087dd2378..a264b4d279 100644 --- a/packages/session-query/session-query/src/invariant.ts +++ b/packages/session-query/session-query/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-query' diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index 1b139feacd..14422f4366 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index acc993d2b3..5a4228329b 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' -import { Context, type Fiber } from 'cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence, { SessionPersistenceCorruptionError, SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 24e8622ab2..8c9588be26 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence from '@deepseek-ai/dsh-session-persistence' diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 39b5ada943..0e8f8c95ee 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -32,10 +32,10 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -51,6 +51,6 @@ "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index 5187e254ae..d204184cfe 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-tool-session-query */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/session-query/tool-session-query/src/invariant.ts b/packages/session-query/tool-session-query/src/invariant.ts index 73f0e35409..3c8fd74114 100644 --- a/packages/session-query/tool-session-query/src/invariant.ts +++ b/packages/session-query/tool-session-query/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-session-query' diff --git a/packages/session-query/tool-session-query/src/operations.ts b/packages/session-query/tool-session-query/src/operations.ts index f169842823..a8010c0e90 100644 --- a/packages/session-query/tool-session-query/src/operations.ts +++ b/packages/session-query/tool-session-query/src/operations.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-tool-session-query/operations */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import { diff --git a/packages/session-query/tool-session-query/src/service-boundary.ts b/packages/session-query/tool-session-query/src/service-boundary.ts index 495897fddd..9fbfc0c55c 100644 --- a/packages/session-query/tool-session-query/src/service-boundary.ts +++ b/packages/session-query/tool-session-query/src/service-boundary.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-tool-session-query/service-boundary */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import { SessionQueryError, diff --git a/packages/session-query/tool-session-query/src/workspace-access.ts b/packages/session-query/tool-session-query/src/workspace-access.ts index faba3adf9f..a6c7b1ce40 100644 --- a/packages/session-query/tool-session-query/src/workspace-access.ts +++ b/packages/session-query/tool-session-query/src/workspace-access.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-tool-session-query/workspace-access */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import { SessionId, diff --git a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts index a00049eb3a..059e32faf1 100644 --- a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index 23ca3f939e..6741adc6eb 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context, type Fiber } from 'cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, CallId, HarnessError , createMessage } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS, TimeoutReason } from '@deepseek-ai/dsh-timeout' diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index afbc62345e..6a7a07ad13 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -31,10 +31,10 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -45,6 +45,6 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session/session-checkpoint-policy/src/index.ts b/packages/session/session-checkpoint-policy/src/index.ts index 804ed0dcb1..0fc456eb08 100644 --- a/packages/session/session-checkpoint-policy/src/index.ts +++ b/packages/session/session-checkpoint-policy/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-session-checkpoint-policy */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session } from '@deepseek-ai/dsh-session' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionResult } from '@deepseek-ai/dsh-tools' diff --git a/packages/session/session-checkpoint-policy/src/invariant.ts b/packages/session/session-checkpoint-policy/src/invariant.ts index f6baece911..af12673ea1 100644 --- a/packages/session/session-checkpoint-policy/src/invariant.ts +++ b/packages/session/session-checkpoint-policy/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/packages/session/session-checkpoint-policy/tests/crash-recovery.e2e.ts b/packages/session/session-checkpoint-policy/tests/crash-recovery.e2e.ts index b64ce563b9..5d782cee1d 100644 --- a/packages/session/session-checkpoint-policy/tests/crash-recovery.e2e.ts +++ b/packages/session/session-checkpoint-policy/tests/crash-recovery.e2e.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { execa } from 'execa' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import SessionStore, { SessionId, TOOL_OUTCOME_UNKNOWN, diff --git a/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts b/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts index a27da79e10..288dd21a26 100644 --- a/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts +++ b/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts @@ -1,5 +1,5 @@ import { writeFile } from 'node:fs/promises' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createUserMessage, CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index dde59610c5..6501941c77 100644 --- a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import LlmService, { CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index b09211669b..9960565102 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -28,16 +28,16 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "koffi": "^3.1.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 8d4aad8e7a..a3a4e04164 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-session-persistence-jsonl */ -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { readdirSync } from 'node:fs' import { open, mkdir, readFile, readdir, realpath, link, rm, stat, truncate } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' diff --git a/packages/session/session-persistence-jsonl/src/invariant.ts b/packages/session/session-persistence-jsonl/src/invariant.ts index 94d7c2b494..c48a083e8f 100644 --- a/packages/session/session-persistence-jsonl/src/invariant.ts +++ b/packages/session/session-persistence-jsonl/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-jsonl' diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index c7b4ab8841..cdb239a982 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1,6 +1,6 @@ import { MessageId, createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { isAbsolute, join, relative, resolve } from 'node:path' diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index b459fcac43..569c15cdd1 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { appendFile, mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' import type { FileHandle } from 'node:fs/promises' import { tmpdir } from 'node:os' diff --git a/packages/session/session-persistence-sqlite/package.json b/packages/session/session-persistence-sqlite/package.json index 90415bd8cc..62bbba8dba 100644 --- a/packages/session/session-persistence-sqlite/package.json +++ b/packages/session/session-persistence-sqlite/package.json @@ -28,15 +28,15 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index b26e273cf1..ab674469d9 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-session-persistence-sqlite */ -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { randomUUID } from 'node:crypto' import { statSync } from 'node:fs' import { DatabaseSync } from 'node:sqlite' diff --git a/packages/session/session-persistence-sqlite/src/invariant.ts b/packages/session/session-persistence-sqlite/src/invariant.ts index 9d841a053d..7a5e905e30 100644 --- a/packages/session/session-persistence-sqlite/src/invariant.ts +++ b/packages/session/session-persistence-sqlite/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-sqlite' diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts index afaa060490..5f8f910bde 100644 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { existsSync } from 'node:fs' import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 10517be615..94b1455beb 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -29,7 +29,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -37,6 +37,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index ec4fb72aeb..be1edf01c3 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-session-persistence/coordinator */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { adoptSessionEvent, interruptedTurnClosers, diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 5c3df73b30..dc06517367 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-session-persistence */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { SessionPreparation } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceRevision } from './revision.ts' @@ -45,7 +45,7 @@ export type { StoredSuffix, } from './coordinator.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sessionPersistence: SessionPersistence } diff --git a/packages/session/session-persistence/src/invariant.ts b/packages/session/session-persistence/src/invariant.ts index 316774f3fd..4259c1b065 100644 --- a/packages/session/session-persistence/src/invariant.ts +++ b/packages/session/session-persistence/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence' diff --git a/packages/session/session-persistence/tests/coordinator-contract.ts b/packages/session/session-persistence/tests/coordinator-contract.ts index 411df34d8d..c272109f82 100644 --- a/packages/session/session-persistence/tests/coordinator-contract.ts +++ b/packages/session/session-persistence/tests/coordinator-contract.ts @@ -11,7 +11,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' */ import { describe, expect, it, vi } from 'vitest' -import { Context, type Fiber } from 'cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index d3e715b085..b9f7b8672b 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, SessionId, isJsonValue } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index 2be692665f..d54b0d18e7 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -25,7 +25,7 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "^3.18.0", "zod": "^4.4.3" }, "peerDependencies": { @@ -34,7 +34,7 @@ "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-session-projection": "^0.0.1", "@deepseek-ai/dsh-storage-domain": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -43,6 +43,6 @@ "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index 03b3ed26ee..66ad637402 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -12,8 +12,8 @@ * @module @deepseek-ai/dsh-session-projection-cache */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' // Empty type import: applies the package's cordis Context merge @@ -27,7 +27,7 @@ import type { CheckpointIdentity, CheckpointRecord } from './spec.ts' export { checkpointIdentity, checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts' export type { CheckpointIdentity, CheckpointRecord } from './spec.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sessionProjectionCache: SessionProjectionCache } diff --git a/packages/session/session-projection-cache/src/invariant.ts b/packages/session/session-projection-cache/src/invariant.ts index 8a119044d8..886f913d30 100644 --- a/packages/session/session-projection-cache/src/invariant.ts +++ b/packages/session/session-projection-cache/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-projection-cache' diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index ba9dc39c57..6395ed7d0f 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -7,7 +7,7 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import Storage from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index 37a081abfa..46445673ab 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -35,11 +35,11 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 154a5ca2fa..9f0c24e72e 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -17,11 +17,11 @@ * @module @deepseek-ai/dsh-session-projection */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { ZodType } from 'zod' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sessionProjections: SessionProjectionRegistry } diff --git a/packages/session/session-projection/src/invariant.ts b/packages/session/session-projection/src/invariant.ts index 47934c946c..537015d932 100644 --- a/packages/session/session-projection/src/invariant.ts +++ b/packages/session/session-projection/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-projection' diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 5d0f208743..4ae4a3face 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -8,7 +8,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import SessionStore from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 2af0b5294e..9a8c4e914f 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -31,7 +31,7 @@ "@opentelemetry/otlp-exporter-base": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-command-feedback": "^0.0.1", @@ -40,16 +40,16 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-telemetry": "^0.0.1", "@deepseek-ai/dsh-user-id": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-command-feedback": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-telemetry": "workspace:^", "@deepseek-ai/dsh-user-id": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index 50776f7d3f..5a5102ca51 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -13,8 +13,8 @@ */ import { createRequire } from 'node:module' -import z from 'schemastery' -import type { Context } from 'cordis' +import z from '@deepseek-ai/schemastery' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-command-feedback' import { Telemetry, diff --git a/packages/session/session-telemetry-otel/src/invariant.ts b/packages/session/session-telemetry-otel/src/invariant.ts index 0eaffeb8b5..31864acb1c 100644 --- a/packages/session/session-telemetry-otel/src/invariant.ts +++ b/packages/session/session-telemetry-otel/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-telemetry-otel' diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index 511c95c0d8..3e14bf3c9d 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -12,9 +12,9 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { gunzipSync } from 'node:zlib' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' -import Loader from '@cordisjs/plugin-loader' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { recordFeedback } from '@deepseek-ai/dsh-command-feedback' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import TelemetryOtel, { Config, DEFAULT_TELEMETRY_MODE, TelemetryMode } from '../src/index.ts' diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index ff6e31b025..2176d02313 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -28,12 +28,12 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session/session-telemetry/src/coordinator.ts b/packages/session/session-telemetry/src/coordinator.ts index f06d1b7b9e..2d092b4fd7 100644 --- a/packages/session/session-telemetry/src/coordinator.ts +++ b/packages/session/session-telemetry/src/coordinator.ts @@ -14,7 +14,7 @@ * @module @deepseek-ai/dsh-session-telemetry/coordinator */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { TelemetryBackend, TelemetryRecord, TelemetrySeverity } from './index.ts' diff --git a/packages/session/session-telemetry/src/index.ts b/packages/session/session-telemetry/src/index.ts index 7ddd85fe8e..19b58d1ee2 100644 --- a/packages/session/session-telemetry/src/index.ts +++ b/packages/session/session-telemetry/src/index.ts @@ -14,9 +14,9 @@ * @module @deepseek-ai/dsh-session-telemetry */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { telemetry: Telemetry } diff --git a/packages/session/session-telemetry/src/invariant.ts b/packages/session/session-telemetry/src/invariant.ts index 1c265877f8..ec0928a868 100644 --- a/packages/session/session-telemetry/src/invariant.ts +++ b/packages/session/session-telemetry/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-telemetry' diff --git a/packages/session/session-telemetry/tests/redact.spec.ts b/packages/session/session-telemetry/tests/redact.spec.ts index f20891fc0f..15df6f380a 100644 --- a/packages/session/session-telemetry/tests/redact.spec.ts +++ b/packages/session/session-telemetry/tests/redact.spec.ts @@ -6,7 +6,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { TelemetryCoordinator, diff --git a/packages/session/session-telemetry/tests/telemetry.spec.ts b/packages/session/session-telemetry/tests/telemetry.spec.ts index 029381fe78..8d758f56b3 100644 --- a/packages/session/session-telemetry/tests/telemetry.spec.ts +++ b/packages/session/session-telemetry/tests/telemetry.spec.ts @@ -7,7 +7,7 @@ import { createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import { diff --git a/packages/session/session-title-all-messages-llm/package.json b/packages/session/session-title-all-messages-llm/package.json index 546387c682..70c3b4c255 100644 --- a/packages/session/session-title-all-messages-llm/package.json +++ b/packages/session/session-title-all-messages-llm/package.json @@ -25,10 +25,10 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-title": "^0.0.1", "@deepseek-ai/dsh-session-title-llm": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -36,6 +36,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-llm": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session/session-title-all-messages-llm/src/index.ts b/packages/session/session-title-all-messages-llm/src/index.ts index 96bd424434..168aa4b98f 100644 --- a/packages/session/session-title-all-messages-llm/src/index.ts +++ b/packages/session/session-title-all-messages-llm/src/index.ts @@ -1,7 +1,7 @@ /** All-human-messages model provider for `ctx.sessionTitle`. */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { registerSessionTitleLlmProvider, SessionTitleLlmConfigFields, diff --git a/packages/session/session-title-all-messages-llm/src/invariant.ts b/packages/session/session-title-all-messages-llm/src/invariant.ts index 79f6eb55ee..1e344d85d9 100644 --- a/packages/session/session-title-all-messages-llm/src/invariant.ts +++ b/packages/session/session-title-all-messages-llm/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-all-messages-llm' diff --git a/packages/session/session-title-all-messages-llm/tests/provider.spec.ts b/packages/session/session-title-all-messages-llm/tests/provider.spec.ts index 3c6ef6ed0b..6eca8abcbf 100644 --- a/packages/session/session-title-all-messages-llm/tests/provider.spec.ts +++ b/packages/session/session-title-all-messages-llm/tests/provider.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import LlmService, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/session/session-title-first-message-llm/package.json b/packages/session/session-title-first-message-llm/package.json index 50b4a4d14c..0c934c192b 100644 --- a/packages/session/session-title-first-message-llm/package.json +++ b/packages/session/session-title-first-message-llm/package.json @@ -25,20 +25,20 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-title": "^0.0.1", "@deepseek-ai/dsh-session-title-llm": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-llm": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session/session-title-first-message-llm/src/index.ts b/packages/session/session-title-first-message-llm/src/index.ts index 51cc8eab44..15aece3fb8 100644 --- a/packages/session/session-title-first-message-llm/src/index.ts +++ b/packages/session/session-title-first-message-llm/src/index.ts @@ -1,7 +1,7 @@ /** First-human-message model provider for `ctx.sessionTitle`. */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { registerSessionTitleLlmProvider, SessionTitleLlmConfigFields, diff --git a/packages/session/session-title-first-message-llm/src/invariant.ts b/packages/session/session-title-first-message-llm/src/invariant.ts index bd3662496f..a2e1b5ff91 100644 --- a/packages/session/session-title-first-message-llm/src/invariant.ts +++ b/packages/session/session-title-first-message-llm/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-first-message-llm' diff --git a/packages/session/session-title-first-message-llm/tests/loader-composition.spec.ts b/packages/session/session-title-first-message-llm/tests/loader-composition.spec.ts index eb0125efd9..28915308a9 100644 --- a/packages/session/session-title-first-message-llm/tests/loader-composition.spec.ts +++ b/packages/session/session-title-first-message-llm/tests/loader-composition.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/session/session-title-first-message-llm/tests/provider.e2e.ts b/packages/session/session-title-first-message-llm/tests/provider.e2e.ts index 6f39669cc8..970c45b851 100644 --- a/packages/session/session-title-first-message-llm/tests/provider.e2e.ts +++ b/packages/session/session-title-first-message-llm/tests/provider.e2e.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-title-first-message-llm/tests/provider.spec.ts b/packages/session/session-title-first-message-llm/tests/provider.spec.ts index 79a80745e3..38d41857ed 100644 --- a/packages/session/session-title-first-message-llm/tests/provider.spec.ts +++ b/packages/session/session-title-first-message-llm/tests/provider.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import LlmService, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index 64e4519662..6e5acac31f 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -30,10 +30,10 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-title": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session/session-title-llm/src/index.ts b/packages/session/session-title-llm/src/index.ts index 52572db30d..711d9c5f16 100644 --- a/packages/session/session-title-llm/src/index.ts +++ b/packages/session/session-title-llm/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-session-title-llm */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { createUserMessage, BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm' import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' diff --git a/packages/session/session-title-llm/src/invariant.ts b/packages/session/session-title-llm/src/invariant.ts index 64350ebf20..4c0a98e830 100644 --- a/packages/session/session-title-llm/src/invariant.ts +++ b/packages/session/session-title-llm/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-llm' diff --git a/packages/session/session-title-llm/tests/llm.spec.ts b/packages/session/session-title-llm/tests/llm.spec.ts index 6572eb89c5..48ae497a7b 100644 --- a/packages/session/session-title-llm/tests/llm.spec.ts +++ b/packages/session/session-title-llm/tests/llm.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import LlmService, { createUserMessage, CallId, isAgentLoopRequest, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { FinishReason, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index d167b257b2..820df4c70d 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -39,10 +39,10 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-projection": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "^3.18.0", "zod": "^4.4.3" }, "devDependencies": { @@ -53,6 +53,6 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 6f8594edd4..8017555ad3 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -3,8 +3,8 @@ * @module @deepseek-ai/dsh-session-title */ -import { Context, FiberState, Service, type Fiber } from 'cordis' -import z from 'schemastery' +import { Context, FiberState, Service, type Fiber } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { Branded } from '@deepseek-ai/dsh-brand' import { assertNever, deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm' @@ -85,7 +85,7 @@ export interface Config { readonly maxTitleBytes: number } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sessionTitle: SessionTitleService } diff --git a/packages/session/session-title/src/invariant.ts b/packages/session/session-title/src/invariant.ts index a826104489..11337fbf34 100644 --- a/packages/session/session-title/src/invariant.ts +++ b/packages/session/session-title/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-title/tests/invariant.spec.ts b/packages/session/session-title/tests/invariant.spec.ts index d1a6abb527..68c82146a4 100644 --- a/packages/session/session-title/tests/invariant.spec.ts +++ b/packages/session/session-title/tests/invariant.spec.ts @@ -1,7 +1,7 @@ // Title-source invariant: `messageSeqs` is empty iff `source.kind` is `user`. // — the durable relationship every appended session/title event must keep. import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as SessionTitleInvariantCompanion from '@deepseek-ai/dsh-session-title/invariant' import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-title/tests/persistence.spec.ts b/packages/session/session-title/tests/persistence.spec.ts index 7d5428983f..3f9ab211d5 100644 --- a/packages/session/session-title/tests/persistence.spec.ts +++ b/packages/session/session-title/tests/persistence.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts index 986cafc620..1198e78a23 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -10,7 +10,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts index 5bfe30ac14..65d913677c 100644 --- a/packages/session/session-title/tests/provider.spec.ts +++ b/packages/session/session-title/tests/provider.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import LlmService, { createUserMessage, deepFreeze, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-title/tests/rename.spec.ts b/packages/session/session-title/tests/rename.spec.ts index ff38900f47..76dfc57e30 100644 --- a/packages/session/session-title/tests/rename.spec.ts +++ b/packages/session/session-title/tests/rename.spec.ts @@ -1,7 +1,7 @@ // SessionTitleService.rename: user-source acceptance, normalization/rejection // boundaries, and the pin (a user-sourced latest title schedules no automatic // revision; explicit refresh stays the unpin). -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-title/tests/service-contracts.spec.ts b/packages/session/session-title/tests/service-contracts.spec.ts index 28d7e6749e..e57ce60f4d 100644 --- a/packages/session/session-title/tests/service-contracts.spec.ts +++ b/packages/session/session-title/tests/service-contracts.spec.ts @@ -1,5 +1,5 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { Context, type Fiber } from 'cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionTitleService, { diff --git a/packages/session/session-title/tests/session-title.spec.ts b/packages/session/session-title/tests/session-title.spec.ts index c6c3c4b9d3..bb8ce4625d 100644 --- a/packages/session/session-title/tests/session-title.spec.ts +++ b/packages/session/session-title/tests/session-title.spec.ts @@ -1,5 +1,5 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionTitleService, { diff --git a/packages/session/user-id/package.json b/packages/session/user-id/package.json index 2a09c73b0e..5e86cc4a32 100644 --- a/packages/session/user-id/package.json +++ b/packages/session/user-id/package.json @@ -28,12 +28,12 @@ "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/session/user-id/src/invariant.ts b/packages/session/user-id/src/invariant.ts index b649e23619..710714c605 100644 --- a/packages/session/user-id/src/invariant.ts +++ b/packages/session/user-id/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-user-id' diff --git a/packages/session/user-id/tests/invariant.spec.ts b/packages/session/user-id/tests/invariant.spec.ts index abffc89621..c140b73711 100644 --- a/packages/session/user-id/tests/invariant.spec.ts +++ b/packages/session/user-id/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import * as UserIdInvariant from '@deepseek-ai/dsh-user-id/invariant' diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json index 6a9185ec2c..5e086b8efe 100644 --- a/packages/settings/settings-local/package.json +++ b/packages/settings/settings-local/package.json @@ -29,11 +29,11 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "chokidar": "^4.0.3", - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "^3.18.0", "yaml": "^2.9.0" }, "devDependencies": { @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index 16bc641f05..5a72afd99a 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -7,8 +7,8 @@ * @module @deepseek-ai/dsh-settings-local */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { watch as chokidarWatch } from 'chokidar' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' diff --git a/packages/settings/settings-local/src/invariant.ts b/packages/settings/settings-local/src/invariant.ts index b59b798298..a5769dae37 100644 --- a/packages/settings/settings-local/src/invariant.ts +++ b/packages/settings/settings-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-settings-local' diff --git a/packages/settings/settings-local/tests/concurrency.spec.ts b/packages/settings/settings-local/tests/concurrency.spec.ts index 5df2179219..138e56cc7c 100644 --- a/packages/settings/settings-local/tests/concurrency.spec.ts +++ b/packages/settings/settings-local/tests/concurrency.spec.ts @@ -3,8 +3,8 @@ // neither knows the other's cache, so only the read-modify-write cycle under // the `<file>.lock` sibling keeps both namespaces alive on disk. import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { chmod, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/settings/settings-local/tests/loader-composition.spec.ts b/packages/settings/settings-local/tests/loader-composition.spec.ts index c7cea89e41..f0c80e3d2d 100644 --- a/packages/settings/settings-local/tests/loader-composition.spec.ts +++ b/packages/settings/settings-local/tests/loader-composition.spec.ts @@ -11,10 +11,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import z from '@deepseek-ai/schemastery' import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings' import SettingsLocal from '../src/index.ts' diff --git a/packages/settings/settings-local/tests/local.spec.ts b/packages/settings/settings-local/tests/local.spec.ts index 8e89cd4643..e2db873764 100644 --- a/packages/settings/settings-local/tests/local.spec.ts +++ b/packages/settings/settings-local/tests/local.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/settings/settings-local/tests/lock-race.spec.ts b/packages/settings/settings-local/tests/lock-race.spec.ts index 46dd24353f..a5f65eab4f 100644 --- a/packages/settings/settings-local/tests/lock-race.spec.ts +++ b/packages/settings/settings-local/tests/lock-race.spec.ts @@ -1,8 +1,8 @@ // A temp-file write failure cannot be timed from outside. The `fs/promises` API // injects it once so the test can prove that the writer lock still releases. import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/settings/settings-local/tests/watcher.spec.ts b/packages/settings/settings-local/tests/watcher.spec.ts index 7b289b22a3..456bfbb87d 100644 --- a/packages/settings/settings-local/tests/watcher.spec.ts +++ b/packages/settings/settings-local/tests/watcher.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 09112147ec..23cb60d13c 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -27,13 +27,13 @@ "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", - "schemastery": "^3.18.0" + "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7", - "schemastery": "^3.18.0" + "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/schemastery": "^3.18.0" } } diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 37d3ec1d50..cacb1c5993 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-settings */ -import { Context, Service } from 'cordis' -import type z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import type z from '@deepseek-ai/schemastery' import type { Branded } from '@deepseek-ai/dsh-brand' import { redactSecrets } from './redact.ts' import type { RedactedSecret } from './redact.ts' @@ -133,7 +133,7 @@ export interface SettingsScope<T> { replace(section: object): Promise<void> } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { settings: Settings } diff --git a/packages/settings/settings/src/invariant.ts b/packages/settings/settings/src/invariant.ts index d8db41bce4..d0e0344b19 100644 --- a/packages/settings/settings/src/invariant.ts +++ b/packages/settings/settings/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-settings/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { deepEqualJson } from './index.ts' diff --git a/packages/settings/settings/src/redact.ts b/packages/settings/settings/src/redact.ts index c9f4cda347..18664bcbbe 100644 --- a/packages/settings/settings/src/redact.ts +++ b/packages/settings/settings/src/redact.ts @@ -7,7 +7,7 @@ * @module @deepseek-ai/dsh-settings/redact */ -import type z from 'schemastery' +import type z from '@deepseek-ai/schemastery' /** * Minimal structural view of a live schemastery node. Only the relations the diff --git a/packages/settings/settings/tests/invariant.spec.ts b/packages/settings/settings/tests/invariant.spec.ts index 0976827368..179658e3ef 100644 --- a/packages/settings/settings/tests/invariant.spec.ts +++ b/packages/settings/settings/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import InvariantService from '@deepseek-ai/dsh-invariants' import * as SettingsInvariant from '../src/invariant.ts' import { settingsNamespace } from '../src/index.ts' diff --git a/packages/settings/settings/tests/redact.spec.ts b/packages/settings/settings/tests/redact.spec.ts index fff6902ea6..dbf02e8ba8 100644 --- a/packages/settings/settings/tests/redact.spec.ts +++ b/packages/settings/settings/tests/redact.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { redactSecrets, settingsNamespace } from '../src/index.ts' import { MemorySettings } from './memory.ts' diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index fb0efe1ff8..154f7ace4e 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { Settings, SettingsConflictError, deepEqualJson, installSettingsSection, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' import { MemorySettings } from './memory.ts' diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index b9dc53d9a5..7df1703c0e 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -27,11 +27,11 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/skill/skill-badge/src/index.ts b/packages/skill/skill-badge/src/index.ts index 9cff2070fb..753d51f817 100644 --- a/packages/skill/skill-badge/src/index.ts +++ b/packages/skill/skill-badge/src/index.ts @@ -6,7 +6,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { BUNDLED_SKILL_RANK, type SkillCandidate, diff --git a/packages/skill/skill-badge/src/invariant.ts b/packages/skill/skill-badge/src/invariant.ts index c087d5917f..7f36b07a7d 100644 --- a/packages/skill/skill-badge/src/invariant.ts +++ b/packages/skill/skill-badge/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-skill-badge' diff --git a/packages/skill/skill-badge/tests/skill-badge.spec.ts b/packages/skill/skill-badge/tests/skill-badge.spec.ts index e4d62f1c89..1da2765a38 100644 --- a/packages/skill/skill-badge/tests/skill-badge.spec.ts +++ b/packages/skill/skill-badge/tests/skill-badge.spec.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillBadge from '@deepseek-ai/dsh-skill-badge' diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index 25306774b9..24ce31af02 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -29,11 +29,11 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "chokidar": "^5.0.0", - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "^3.18.0", "yaml": "^2.4.2" }, "devDependencies": { @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 912feaa2ca..190a38c8ee 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -13,10 +13,10 @@ import { access, lstat, readdir, readFile, stat } from 'node:fs/promises' import { unwatchFile, watchFile, type Stats } from 'node:fs' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { homedir } from 'node:os' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import chokidar from 'chokidar' -import z from 'schemastery' -import type Schema from 'schemastery' +import z from '@deepseek-ai/schemastery' +import type Schema from '@deepseek-ai/schemastery' import { parse as parseYaml } from 'yaml' import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs' import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths' diff --git a/packages/skill/skill-local/src/invariant.ts b/packages/skill/skill-local/src/invariant.ts index 6d4917a4d9..37ddd64c95 100644 --- a/packages/skill/skill-local/src/invariant.ts +++ b/packages/skill/skill-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-skill-local' diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index 6aa0ef5ca4..dc4d954272 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -4,7 +4,7 @@ import { mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SkillService from '@deepseek-ai/dsh-skill' interface FakeWatcherControl { diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 5bf1d24856..5948ace8a4 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { mkdir, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SkillService from '@deepseek-ai/dsh-skill' import { FileSystem, FsError, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs' import * as SkillLocal from '../src/index.ts' diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index da51610ec3..20309664cd 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -28,15 +28,15 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index c013933547..f5ba597820 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -10,12 +10,12 @@ * @module @deepseek-ai/dsh-skill */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { assertNever } from '@deepseek-ai/dsh-llm' import { NamedEntries, ScopedLayers, scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' -import z from 'schemastery' -import type Schema from 'schemastery' +import z from '@deepseek-ai/schemastery' +import type Schema from '@deepseek-ai/schemastery' const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ const DEFAULT_COLLECT_CACHE_ENTRIES = 128 @@ -281,7 +281,7 @@ export interface Config { readonly collectCacheMaxEntries?: number } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { skills: SkillService } diff --git a/packages/skill/skill/src/invariant.ts b/packages/skill/skill/src/invariant.ts index 5145dee6da..b047a4476e 100644 --- a/packages/skill/skill/src/invariant.ts +++ b/packages/skill/skill/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-skill' diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index a5e03b610a..7dae5f5cf6 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import SkillService, { isModelInvocable, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index e3ff7520b5..07adab663a 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -30,10 +30,10 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -44,6 +44,6 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 634fb7ce02..e2a0cc2dca 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -5,8 +5,8 @@ */ import { createHash } from 'node:crypto' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/skill/tool-skill/src/invariant.ts b/packages/skill/tool-skill/src/invariant.ts index 68d70fa2d2..770edf99e5 100644 --- a/packages/skill/tool-skill/src/invariant.ts +++ b/packages/skill/tool-skill/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-skill' diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 496fe81d46..f4e5ab9f8e 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId, type Message } from '@deepseek-ai/dsh-llm' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index f185ab2c11..fcfacf1dcf 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -27,10 +27,10 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-spill": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -38,6 +38,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" } } diff --git a/packages/spill/spill-local/src/index.ts b/packages/spill/spill-local/src/index.ts index 73e2cad851..54e2e6cd6d 100644 --- a/packages/spill/spill-local/src/index.ts +++ b/packages/spill/spill-local/src/index.ts @@ -8,9 +8,9 @@ * @module @deepseek-ai/dsh-spill-local */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { resolve } from 'node:path' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import { privateRoot, saveTextFile } from './store.ts' diff --git a/packages/spill/spill-local/src/invariant.ts b/packages/spill/spill-local/src/invariant.ts index 4b44ddbebf..2651172635 100644 --- a/packages/spill/spill-local/src/invariant.ts +++ b/packages/spill/spill-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill-local' diff --git a/packages/spill/spill-local/tests/spill-local.spec.ts b/packages/spill/spill-local/tests/spill-local.spec.ts index 3c6f9ac82d..fd01babeff 100644 --- a/packages/spill/spill-local/tests/spill-local.spec.ts +++ b/packages/spill/spill-local/tests/spill-local.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it, beforeEach, afterEach } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, dirname, isAbsolute, join, normalize } from 'node:path' diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index f238acd3d6..3d6cda2186 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -31,10 +31,10 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-spill": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -45,6 +45,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" } } diff --git a/packages/spill/spill-policy/src/index.ts b/packages/spill/spill-policy/src/index.ts index 45d7ba2e74..ca71dd8588 100644 --- a/packages/spill/spill-policy/src/index.ts +++ b/packages/spill/spill-policy/src/index.ts @@ -43,8 +43,8 @@ * @module @deepseek-ai/dsh-spill-policy */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { TextRetainer, describeOmitted } from '@deepseek-ai/dsh-retention' import type { Omitted } from '@deepseek-ai/dsh-retention' diff --git a/packages/spill/spill-policy/src/invariant.ts b/packages/spill/spill-policy/src/invariant.ts index 82a4bee211..860b4c5187 100644 --- a/packages/spill/spill-policy/src/invariant.ts +++ b/packages/spill/spill-policy/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill-policy' diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index 0d35927cab..ff8afa22dd 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -9,8 +9,8 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index 6a87d575a3..996069461e 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -29,13 +29,13 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" } } diff --git a/packages/spill/spill/src/index.ts b/packages/spill/spill/src/index.ts index 0d98ea9f66..ed0f8d33eb 100644 --- a/packages/spill/spill/src/index.ts +++ b/packages/spill/spill/src/index.ts @@ -14,13 +14,13 @@ * @module @deepseek-ai/dsh-spill */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { SaveTextSpill, SpillRef } from './types.ts' export { SpillLocator } from './types.ts' export type { SaveTextSpill, SpillOwner, SpillRef, SpillSource } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { spillStore: SpillStore } diff --git a/packages/spill/spill/src/invariant.ts b/packages/spill/spill/src/invariant.ts index 5011ac1d52..34e39d2e1d 100644 --- a/packages/spill/spill/src/invariant.ts +++ b/packages/spill/spill/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill' diff --git a/packages/spill/spill/tests/service.spec.ts b/packages/spill/spill/tests/service.spec.ts index 141072d67a..c17a77b273 100644 --- a/packages/spill/spill/tests/service.spec.ts +++ b/packages/spill/spill/tests/service.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index aaa277b244..fe5915c2ea 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -27,15 +27,15 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-storage": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "^3.18.0", "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/storage/storage-domain/src/domain.ts b/packages/storage/storage-domain/src/domain.ts index 26ed726b93..c4087d6dd2 100644 --- a/packages/storage/storage-domain/src/domain.ts +++ b/packages/storage/storage-domain/src/domain.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-storage-domain/src/domain */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { KvUnit } from '@deepseek-ai/dsh-storage' import { DomainError } from './error.ts' import type { DomainSpec, DomainGlobalSpec, TableKeyOf, TableValueOf } from './spec.ts' diff --git a/packages/storage/storage-domain/src/events.ts b/packages/storage/storage-domain/src/events.ts index f70095e5d5..610182d07a 100644 --- a/packages/storage/storage-domain/src/events.ts +++ b/packages/storage/storage-domain/src/events.ts @@ -33,7 +33,7 @@ export interface DomainChangedDeleted extends DomainChangedBase { /** One durable domain change; a closed union — switch on `operation`. */ export type DomainChanged = DomainChangedPut | DomainChangedDeleted -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { /** * A domain record or the global singleton changed, emitted once per write diff --git a/packages/storage/storage-domain/src/index.ts b/packages/storage/storage-domain/src/index.ts index cb6c5f0f77..d2c16a3d69 100644 --- a/packages/storage/storage-domain/src/index.ts +++ b/packages/storage/storage-domain/src/index.ts @@ -7,8 +7,8 @@ * @module @deepseek-ai/dsh-storage-domain */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import { DomainError } from './error.ts' import { descriptorOf } from './spec.ts' @@ -32,7 +32,7 @@ declare module '@deepseek-ai/dsh-storage' { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { storageDomain: DomainFacility } diff --git a/packages/storage/storage-domain/src/invariant.ts b/packages/storage/storage-domain/src/invariant.ts index b2da8bebe4..5386149115 100644 --- a/packages/storage/storage-domain/src/invariant.ts +++ b/packages/storage/storage-domain/src/invariant.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-storage-domain/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { DomainChanged } from './events.ts' diff --git a/packages/storage/storage-domain/tests/domain.spec.ts b/packages/storage/storage-domain/tests/domain.spec.ts index 4a083b3f78..5e1afbc863 100644 --- a/packages/storage/storage-domain/tests/domain.spec.ts +++ b/packages/storage/storage-domain/tests/domain.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import { apply, DomainFacility, defineDomain, domainTable } from '../src/index.ts' diff --git a/packages/storage/storage-domain/tests/invariant.spec.ts b/packages/storage/storage-domain/tests/invariant.spec.ts index 80c7264aae..9efe42c108 100644 --- a/packages/storage/storage-domain/tests/invariant.spec.ts +++ b/packages/storage/storage-domain/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import Storage from '@deepseek-ai/dsh-storage' import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index 4dc9bec40b..55012ed819 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -27,14 +27,14 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-storage": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/storage/storage-json/src/index.ts b/packages/storage/storage-json/src/index.ts index c2c0ac0dd8..69c42cd194 100644 --- a/packages/storage/storage-json/src/index.ts +++ b/packages/storage/storage-json/src/index.ts @@ -7,8 +7,8 @@ import { mkdir } from 'node:fs/promises' import { join } from 'node:path' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { StorageError, UNIT_NAME_RE, storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage' import { openJsonUnit } from './unit.ts' diff --git a/packages/storage/storage-json/src/invariant.ts b/packages/storage/storage-json/src/invariant.ts index 3f3ec4a2d1..915a898e8f 100644 --- a/packages/storage/storage-json/src/invariant.ts +++ b/packages/storage/storage-json/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-storage-json' diff --git a/packages/storage/storage-json/tests/json-backend.spec.ts b/packages/storage/storage-json/tests/json-backend.spec.ts index b8d37eabb9..727981b49b 100644 --- a/packages/storage/storage-json/tests/json-backend.spec.ts +++ b/packages/storage/storage-json/tests/json-backend.spec.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promise import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import InvariantService from '@deepseek-ai/dsh-invariants' import { runKvBackendContract } from '../../storage/tests/contract.ts' diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index cebde3d279..6283822419 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -27,14 +27,14 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-storage": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/storage/storage-sqlite/src/index.ts b/packages/storage/storage-sqlite/src/index.ts index eff5bb80fb..5cd59cadc2 100644 --- a/packages/storage/storage-sqlite/src/index.ts +++ b/packages/storage/storage-sqlite/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-storage-sqlite */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { DatabaseSync } from 'node:sqlite' import { StorageError, UNIT_NAME_RE, storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage' diff --git a/packages/storage/storage-sqlite/src/invariant.ts b/packages/storage/storage-sqlite/src/invariant.ts index cbfadc8442..1775bd9524 100644 --- a/packages/storage/storage-sqlite/src/invariant.ts +++ b/packages/storage/storage-sqlite/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-storage-sqlite' diff --git a/packages/storage/storage-sqlite/tests/invariant.spec.ts b/packages/storage/storage-sqlite/tests/invariant.spec.ts index 0c23906ca4..3255549a0c 100644 --- a/packages/storage/storage-sqlite/tests/invariant.spec.ts +++ b/packages/storage/storage-sqlite/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import * as StorageSqliteInvariant from '../src/invariant.ts' diff --git a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts index 37cf7ba122..f2fd65ee42 100644 --- a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts +++ b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 1c78c1d434..094814dfa1 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -26,10 +26,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/storage/storage/src/index.ts b/packages/storage/storage/src/index.ts index 5312513591..9344558e09 100644 --- a/packages/storage/storage/src/index.ts +++ b/packages/storage/storage/src/index.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-storage */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { StorageError } from './error.ts' import { BackendRegistry } from './registry.ts' @@ -27,7 +27,7 @@ export function storageBackendServiceKey(name: string): string { return `storage.backend.${name}` } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { storage: Storage } diff --git a/packages/storage/storage/src/invariant.ts b/packages/storage/storage/src/invariant.ts index cac811a39a..1c303c43fa 100644 --- a/packages/storage/storage/src/invariant.ts +++ b/packages/storage/storage/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-storage' diff --git a/packages/storage/storage/tests/registry.spec.ts b/packages/storage/storage/tests/registry.spec.ts index 232efc3640..c23a9a36c7 100644 --- a/packages/storage/storage/tests/registry.spec.ts +++ b/packages/storage/storage/tests/registry.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import Storage, { BackendRegistry, storageBackendServiceKey } from '../src/index.ts' import type { StorageBackend } from '../src/index.ts' diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 424cb9cb5c..a99154af56 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -32,14 +32,14 @@ "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "@agentclientprotocol/sdk": "0.25.1", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -49,6 +49,6 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index af126b86de..4b526279ba 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -9,8 +9,8 @@ import { accessSync, constants, statSync } from 'node:fs' import { isAbsolute, resolve } from 'node:path' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { ResolvedSubagentStartRequest, SubagentCapabilities, diff --git a/packages/subagent/subagent-acp/src/invariant.ts b/packages/subagent/subagent-acp/src/invariant.ts index 85c1601348..536404e6d9 100644 --- a/packages/subagent/subagent-acp/src/invariant.ts +++ b/packages/subagent/subagent-acp/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-acp' diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index 9216068b07..cacee70a2a 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 122d781b6c..291e02c32a 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 8b25c6919c..d2234104cb 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -31,12 +31,12 @@ "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "@anthropic-ai/sdk": "0.93.0", "@anthropic-ai/claude-agent-sdk": "0.3.220", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -48,6 +48,6 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index e4d6fbac5f..ea780401d7 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-subagent-claude-code */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { assertPositiveFinite, diff --git a/packages/subagent/subagent-claude-code/src/invariant.ts b/packages/subagent/subagent-claude-code/src/invariant.ts index 462692590f..44fa400e16 100644 --- a/packages/subagent/subagent-claude-code/src/invariant.ts +++ b/packages/subagent/subagent-claude-code/src/invariant.ts @@ -5,7 +5,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-claude-code' diff --git a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts index 806181ad13..aa77615297 100644 --- a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts @@ -10,7 +10,7 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 00e56c23b8..558724eee2 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -15,7 +15,7 @@ import type { SDKMessage, SDKSystemMessage, } from '@anthropic-ai/claude-agent-sdk' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 8c4ac1708d..b2cc0d7e37 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -6,8 +6,8 @@ import type { SDKResultMessage, SpawnOptions, } from '@anthropic-ai/claude-agent-sdk' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { afterEach, beforeEach, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 30788aa2e5..a14173e3ee 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -32,13 +32,13 @@ "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -50,6 +50,6 @@ "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@openai/codex": "0.147.0", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 23077e3b54..3b1bbec799 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-subagent-codex */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { assertPositiveFinite, diff --git a/packages/subagent/subagent-codex/src/invariant.ts b/packages/subagent/subagent-codex/src/invariant.ts index a0c094af9c..ec9a6302c4 100644 --- a/packages/subagent/subagent-codex/src/invariant.ts +++ b/packages/subagent/subagent-codex/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-codex' diff --git a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts index 5c39ebfb3d..c184a01bc9 100644 --- a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { delimiter, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index c39093b041..ef618aaef0 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { delimiter, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 9318c59787..81923f2228 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1,6 +1,6 @@ import { PassThrough } from 'node:stream' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 07217be7c1..58bdd83e13 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -32,13 +32,13 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -48,6 +48,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-dsh-sdk/src/index.ts b/packages/subagent/subagent-dsh-sdk/src/index.ts index 09a1a64d32..c07cb86456 100644 --- a/packages/subagent/subagent-dsh-sdk/src/index.ts +++ b/packages/subagent/subagent-dsh-sdk/src/index.ts @@ -10,8 +10,8 @@ * @module @deepseek-ai/dsh-subagent-dsh-sdk */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import { assertPositiveFinite, NO_START_CAPABILITIES, resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent' import { diff --git a/packages/subagent/subagent-dsh-sdk/src/invariant.ts b/packages/subagent/subagent-dsh-sdk/src/invariant.ts index 2aca0413ba..cddaed01bf 100644 --- a/packages/subagent/subagent-dsh-sdk/src/invariant.ts +++ b/packages/subagent/subagent-dsh-sdk/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-dsh-sdk' diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index e42a76c90c..26043f6a8d 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index 1a77983d56..57d79a38d4 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -30,13 +30,13 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -46,6 +46,6 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 7257c8dc06..0a3ea21aa2 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -7,8 +7,8 @@ * @module @deepseek-ai/dsh-subagent-fork */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { diff --git a/packages/subagent/subagent-fork/src/invariant.ts b/packages/subagent/subagent-fork/src/invariant.ts index e3d65701b1..7cff3d76f7 100644 --- a/packages/subagent/subagent-fork/src/invariant.ts +++ b/packages/subagent/subagent-fork/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-fork' diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 490b07f77b..7bff34c35b 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 70002b0e88..dcd3d0230d 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -1,7 +1,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index 36646e7f7a..90a30dc785 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -34,7 +34,7 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-sandbox-policy": { @@ -45,8 +45,8 @@ } }, "devDependencies": { - "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-include": "^1.0.4", + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -61,6 +61,6 @@ "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index ee7779447a..92718db905 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -12,7 +12,7 @@ */ import { randomUUID } from 'node:crypto' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts index 4a2188dcc8..e8639e1f9a 100644 --- a/packages/subagent/subagent-inprocess/src/invariant.ts +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-inprocess' diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 6bf5efbd35..d1471b3dde 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -10,7 +10,7 @@ * @module @deepseek-ai/dsh-subagent-inprocess/structured */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ToolSchema } from '@deepseek-ai/dsh-llm' import type { ToolExecution, ToolRunContext } from '@deepseek-ai/dsh-tools' import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools' diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts index 804249ba77..30026b4ba6 100644 --- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts index af199cd867..28306f0dcc 100644 --- a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -10,9 +10,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 06fa641336..0fef7f9ef9 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 46b94f9f5a..c490bc7d3b 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index 243e7afb52..f20b96227c 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -28,13 +28,13 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -48,6 +48,6 @@ "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index d74114f55a..b7b6456270 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-subagent-spawn */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { ContinuableCreateSpec, ResolvedSubagentStartRequest, diff --git a/packages/subagent/subagent-spawn/src/invariant.ts b/packages/subagent/subagent-spawn/src/invariant.ts index 0ba0182f9f..a6f1e1ccca 100644 --- a/packages/subagent/subagent-spawn/src/invariant.ts +++ b/packages/subagent/subagent-spawn/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-spawn' diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index 33de6d0cc6..f8a45aef25 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts index 48220c1d9d..ba76c69203 100644 --- a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts +++ b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { spawnHarness, waitForIdle } from './harness.ts' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 508d5132fb..fdef892d7f 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -1,7 +1,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context, symbols, type EffectMeta } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context, symbols, type EffectMeta } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 35504c9dc4..ddecb9f6b1 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -45,7 +45,7 @@ "@deepseek-ai/dsh-session-projection-cache": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-agent-presets": { @@ -79,6 +79,6 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent/src/activation-setup-registry.ts b/packages/subagent/subagent/src/activation-setup-registry.ts index 5681e89863..9317d5a2ac 100644 --- a/packages/subagent/subagent/src/activation-setup-registry.ts +++ b/packages/subagent/subagent/src/activation-setup-registry.ts @@ -11,7 +11,7 @@ * @module @deepseek-ai/dsh-subagent/activation-setup-registry */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { AgentSetupCommit } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' import { SubagentError } from './error.ts' diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index c501a19a56..e9ca1422ce 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-subagent/child-agent */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 1cb16daca6..1403b29d17 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -15,7 +15,7 @@ */ import { randomUUID } from 'node:crypto' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent, AgentHandle, diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index eddcf63c3c..5c6898723c 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -31,7 +31,7 @@ * @module @deepseek-ai/dsh-subagent */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' @@ -122,7 +122,7 @@ export type { SubagentDescendantListEntry, SubagentListEntry } from './list-chil export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts' export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { subagents: SubagentService } diff --git a/packages/subagent/subagent/src/invariant.ts b/packages/subagent/subagent/src/invariant.ts index d9a497eb77..c2ef451920 100644 --- a/packages/subagent/subagent/src/invariant.ts +++ b/packages/subagent/subagent/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned subagent registry and lifecycle invariants. @module @deepseek-ai/dsh-subagent/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { SubagentProvider, SubagentRunEndInfo, SubagentRunInfo } from './types.ts' diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index 65c61ae9eb..eff0534d9f 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -15,7 +15,7 @@ */ import { randomUUID } from 'node:crypto' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session' diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 8768a002b6..03f8da6c28 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -16,7 +16,7 @@ * @module @deepseek-ai/dsh-subagent */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' diff --git a/packages/subagent/subagent/tests/activation-setup-registry.spec.ts b/packages/subagent/subagent/tests/activation-setup-registry.spec.ts index 6353f486c5..6231ed3936 100644 --- a/packages/subagent/subagent/tests/activation-setup-registry.spec.ts +++ b/packages/subagent/subagent/tests/activation-setup-registry.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SubagentActivationSetupRegistry from '../src/activation-setup-registry.ts' /** A child-like scoped context with observable disposal. */ diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 9370676f76..6bd49312a0 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/subagent/subagent/tests/invariant.spec.ts b/packages/subagent/subagent/tests/invariant.spec.ts index 21bdda0e06..615dd02aa5 100644 --- a/packages/subagent/subagent/tests/invariant.spec.ts +++ b/packages/subagent/subagent/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { SessionId } from '@deepseek-ai/dsh-session' import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index fa2db5fd04..7a7afb5e22 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { z } from 'zod' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index a50696cf2a..5106bc3427 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { type Agent } from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' diff --git a/packages/subagent/subagent/tests/timing-projection.spec.ts b/packages/subagent/subagent/tests/timing-projection.spec.ts index 0165f73714..a854e69df2 100644 --- a/packages/subagent/subagent/tests/timing-projection.spec.ts +++ b/packages/subagent/subagent/tests/timing-projection.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 2848a7d14d..d1ca31c635 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -35,7 +35,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -50,6 +50,6 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index 43e3f52b4e..67c93cda5c 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-tool-subagent-control */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/subagent/tool-subagent-control/src/invariant.ts b/packages/subagent/tool-subagent-control/src/invariant.ts index c993426a26..2538fa5065 100644 --- a/packages/subagent/tool-subagent-control/src/invariant.ts +++ b/packages/subagent/tool-subagent-control/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent-control' diff --git a/packages/subagent/tool-subagent-control/src/list-agents.ts b/packages/subagent/tool-subagent-control/src/list-agents.ts index d47232b3cc..37a4c3bdff 100644 --- a/packages/subagent/tool-subagent-control/src/list-agents.ts +++ b/packages/subagent/tool-subagent-control/src/list-agents.ts @@ -7,7 +7,7 @@ * @module @deepseek-ai/dsh-tool-subagent-control/list-agents */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts index 0361471031..1c12602dd2 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index c5ea8fd1b8..d24f4cac9b 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index 12e39c341c..c3f0e530ec 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -29,10 +29,10 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -47,6 +47,6 @@ "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/tool-subagent-report/src/index.ts b/packages/subagent/tool-subagent-report/src/index.ts index 962d8cf382..b33a5a29ab 100644 --- a/packages/subagent/tool-subagent-report/src/index.ts +++ b/packages/subagent/tool-subagent-report/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-tool-subagent-report */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentReportDelivery } from '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/tool-subagent-report/src/invariant.ts b/packages/subagent/tool-subagent-report/src/invariant.ts index 50abca31c6..93777a70ec 100644 --- a/packages/subagent/tool-subagent-report/src/invariant.ts +++ b/packages/subagent/tool-subagent-report/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent-report' diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 23757c2b54..121fb81f94 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index f7d4c4e84e..d5d0da52d4 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -31,13 +31,13 @@ "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -51,6 +51,6 @@ "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 67894c32cb..31df8f9e9c 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -8,8 +8,8 @@ * @module @deepseek-ai/dsh-tool-subagent */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import type { AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index bd30f4c563..5b8facc900 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent' diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts index 7365348410..84a20e0e68 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { type Agent } from '@deepseek-ai/dsh-agent' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.ts b/packages/subagent/tool-subagent/tests/scripted-provider.ts index 01c0769cf8..df35e6dbdd 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.ts @@ -1,6 +1,6 @@ /** Package-local scripted child boundary for deterministic tool-subagent tests. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 04b127a856..8f289048cb 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -2,8 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 7a68a9b91b..d966c0efb8 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -32,7 +32,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "node-pty": "^1.1.0" @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 858bf3fc75..5242986b3b 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -10,7 +10,7 @@ import { constants } from 'node:fs' import { access, stat } from 'node:fs/promises' import { delimiter, extname, isAbsolute, resolve } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as nodePty from 'node-pty' import type { IPtyForkOptions } from 'node-pty' import { SubprocessService } from '@deepseek-ai/dsh-subprocess' diff --git a/packages/subprocess/subprocess-local/src/invariant.ts b/packages/subprocess/subprocess-local/src/invariant.ts index b15b2dd511..335533d854 100644 --- a/packages/subprocess/subprocess-local/src/invariant.ts +++ b/packages/subprocess/subprocess-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess-local' diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index c4e3f91522..e3131543f4 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -1,7 +1,7 @@ import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import { basename, dirname, relative, resolve } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { childEnv } from '../src/spawn.ts' diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 36da8307e3..4cffde6432 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -669,7 +669,7 @@ describe('tree-survivor escalation (terminate and bounded waits reach helpers th }) it('service teardown awaits tree survivors, not just handle settlement', async () => { - const { Context } = await import('cordis') + const { Context } = await import('@deepseek-ai/cordis') const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local') const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessService) diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index b7aa6308ad..f22708c717 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -26,10 +26,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 081ff436ec..bf30817b57 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-subprocess */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { DSH_ENV_PREFIX } from './types.ts' import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts' @@ -65,7 +65,7 @@ export function scrubbedParentEnv(): Record<string, string> { return env } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { subprocess: SubprocessService } diff --git a/packages/subprocess/subprocess/src/invariant.ts b/packages/subprocess/subprocess/src/invariant.ts index 3a6ae526b0..c9e6ee904e 100644 --- a/packages/subprocess/subprocess/src/invariant.ts +++ b/packages/subprocess/subprocess/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned invariant companion for the subprocess seam. @module @deepseek-ai/dsh-subprocess/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess' diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index e4f770a9a3..e4024b668c 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { PassThrough } from 'node:stream' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { scrubbedParentEnv, SubprocessService } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index b504cbe50d..fb4e13ec1e 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -32,11 +32,11 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/support/acp-snapshot/src/invariant.ts b/packages/support/acp-snapshot/src/invariant.ts index e94876100e..e9aada2488 100644 --- a/packages/support/acp-snapshot/src/invariant.ts +++ b/packages/support/acp-snapshot/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp-snapshot' diff --git a/packages/support/agent-loop-testkit/README.i18n.yaml b/packages/support/agent-loop-testkit/README.i18n.yaml index 2236b6aa8e..80da12640e 100644 --- a/packages/support/agent-loop-testkit/README.i18n.yaml +++ b/packages/support/agent-loop-testkit/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/agent-loop-testkit/README.md -README.md: 18c46069d3cfd402c83b5ecab68458667738163b -README.zh.md: 2151591154899094fe33f9c25c9b9144e6d02a22 +README.md: 3b7225b1cdd1960e4ab9fda36f89d1ab1ad672e3 +README.zh.md: b4e38202f45c3ac8f541e42eaba63acea3666ab8 diff --git a/packages/support/agent-loop-testkit/README.md b/packages/support/agent-loop-testkit/README.md index 18c46069d3..3b7225b1cd 100644 --- a/packages/support/agent-loop-testkit/README.md +++ b/packages/support/agent-loop-testkit/README.md @@ -7,7 +7,7 @@ Shared prerequisite mounting for tests that exercise the concrete `AgentLoop`. ` The caller registers adapters and optional plugins, mounts `AgentLoop` with the configuration under test, and disposes its own Context. System-prompt and tool-registry configuration can be forwarded through `options`; the helper does not provide test defaults beyond those owned by the services. A plugin-load failure rejects the helper call, while services activated earlier in the sequence remain owned by the caller's Context. ```ts -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/support/agent-loop-testkit/README.zh.md b/packages/support/agent-loop-testkit/README.zh.md index 2151591154..b4e38202f4 100644 --- a/packages/support/agent-loop-testkit/README.zh.md +++ b/packages/support/agent-loop-testkit/README.zh.md @@ -7,7 +7,7 @@ 调用方注册适配器和可选插件,使用待测配置挂载 `AgentLoop`,并 dispose(资源释放)自己的 Context。系统提示词和工具注册表配置可通过 `options` 转发;该辅助函数不提供超出服务自有默认值的测试默认值。插件加载失败会使辅助函数调用被拒绝,而顺序中较早激活的服务仍归调用方的 Context 所有。 ```ts -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/support/agent-loop-testkit/package.json b/packages/support/agent-loop-testkit/package.json index d6419f38dd..2bbfa195a7 100644 --- a/packages/support/agent-loop-testkit/package.json +++ b/packages/support/agent-loop-testkit/package.json @@ -31,7 +31,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/support/agent-loop-testkit/src/index.ts b/packages/support/agent-loop-testkit/src/index.ts index c7b0cb7304..95a4052372 100644 --- a/packages/support/agent-loop-testkit/src/index.ts +++ b/packages/support/agent-loop-testkit/src/index.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-agent-loop-testkit */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/support/agent-loop-testkit/src/invariant.ts b/packages/support/agent-loop-testkit/src/invariant.ts index 33ee4474f9..c9e346921e 100644 --- a/packages/support/agent-loop-testkit/src/invariant.ts +++ b/packages/support/agent-loop-testkit/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts b/packages/support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts index aa125b561f..233900fc65 100644 --- a/packages/support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts +++ b/packages/support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { mountAgentLoopTestDependencies } from '../src/index.ts' diff --git a/packages/support/invariants/README.i18n.yaml b/packages/support/invariants/README.i18n.yaml index ea7fbba8f3..3d484e2ba9 100644 --- a/packages/support/invariants/README.i18n.yaml +++ b/packages/support/invariants/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/invariants/README.md -README.md: 9a93187032b6f8e4f5d89e17e742baba41196ff9 -README.zh.md: 7f3fa1e23337e55a73928c3952aa4c925a5fb4e9 +README.md: d3823d05b14afb5f043239cd7153ee40d66f3e4e +README.zh.md: b45e028e0f3865470763360846edfe4beed8a8cb diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 9a93187032..d3823d05b1 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -52,7 +52,7 @@ The root entrypoint of each owner remains independent of diagnostics. Loading th ## Composition ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' diff --git a/packages/support/invariants/README.zh.md b/packages/support/invariants/README.zh.md index 7f3fa1e233..b45e028e0f 100644 --- a/packages/support/invariants/README.zh.md +++ b/packages/support/invariants/README.zh.md @@ -52,7 +52,7 @@ interface Config { ## 组合 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index f41c6bfde6..3ef3a80f41 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -25,12 +25,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 917e5327d4..7bf675243a 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -6,10 +6,10 @@ * @module @deepseek-ai/dsh-invariants */ -import { Context, Service } from 'cordis' -import type { Inject } from 'cordis' -import z from 'schemastery' -import type Schema from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import type { Inject } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type Schema from '@deepseek-ai/schemastery' /** Runtime invariant selection configured on the service plugin. */ export interface Config { @@ -65,7 +65,7 @@ export class InvariantError extends Error { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { invariants: InvariantService } diff --git a/packages/support/invariants/src/invariant.ts b/packages/support/invariants/src/invariant.ts index 7780e987f5..81ecc5b2af 100644 --- a/packages/support/invariants/src/invariant.ts +++ b/packages/support/invariants/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-invariants' diff --git a/packages/support/invariants/tests/service.spec.ts b/packages/support/invariants/tests/service.spec.ts index 9000fb8955..483f769916 100644 --- a/packages/support/invariants/tests/service.spec.ts +++ b/packages/support/invariants/tests/service.spec.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from 'vitest' -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import InvariantService, { InvariantError, type Config, } from '@deepseek-ai/dsh-invariants' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { invariantProbe: InvariantProbeService } diff --git a/packages/support/llm-mock-server/package.json b/packages/support/llm-mock-server/package.json index 20b1cf6d85..c342697f01 100644 --- a/packages/support/llm-mock-server/package.json +++ b/packages/support/llm-mock-server/package.json @@ -26,10 +26,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/support/llm-mock-server/src/invariant.ts b/packages/support/llm-mock-server/src/invariant.ts index b8fbc2dd40..a77bb42835 100644 --- a/packages/support/llm-mock-server/src/invariant.ts +++ b/packages/support/llm-mock-server/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-mock-server' diff --git a/packages/support/llm-mock-server/tests/invariant.spec.ts b/packages/support/llm-mock-server/tests/invariant.spec.ts index f45320d989..3bd7eb6d7b 100644 --- a/packages/support/llm-mock-server/tests/invariant.spec.ts +++ b/packages/support/llm-mock-server/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import * as MockServerInvariant from '../src/invariant.ts' diff --git a/packages/support/llm-replay/package.json b/packages/support/llm-replay/package.json index af5e2929f3..0c85caf96f 100644 --- a/packages/support/llm-replay/package.json +++ b/packages/support/llm-replay/package.json @@ -29,13 +29,13 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 868751d969..22757c53a3 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -9,7 +9,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { delimiter as pathDelimiter } from 'node:path' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-compact' import { decodeStorageRecord } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/support/llm-replay/src/invariant.ts b/packages/support/llm-replay/src/invariant.ts index 36a3f8eeca..64fc06a262 100644 --- a/packages/support/llm-replay/src/invariant.ts +++ b/packages/support/llm-replay/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-replay' diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 4b163fdd95..d0bd675b48 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { CompactionId } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json index 5cb8ef96a2..10dc20f863 100644 --- a/packages/support/loader-smoke/package.json +++ b/packages/support/loader-smoke/package.json @@ -33,13 +33,13 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" } } diff --git a/packages/support/loader-smoke/src/agent-turn.ts b/packages/support/loader-smoke/src/agent-turn.ts index ea3a65d725..5638445cbe 100644 --- a/packages/support/loader-smoke/src/agent-turn.ts +++ b/packages/support/loader-smoke/src/agent-turn.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-loader-smoke/agent-turn */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, type TokenUsage } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/support/loader-smoke/src/invariant.ts b/packages/support/loader-smoke/src/invariant.ts index 1e3cc54b81..36e8d646de 100644 --- a/packages/support/loader-smoke/src/invariant.ts +++ b/packages/support/loader-smoke/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-loader-smoke' diff --git a/packages/support/loader-smoke/tests/agent-turn.spec.ts b/packages/support/loader-smoke/tests/agent-turn.spec.ts index 481b4ef566..4f04efc303 100644 --- a/packages/support/loader-smoke/tests/agent-turn.spec.ts +++ b/packages/support/loader-smoke/tests/agent-turn.spec.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { describe, expect, it, vi } from 'vitest' import { runFixtureTurn } from '../src/agent-turn.ts' diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json index 92e095ffa9..76deaf9b8d 100644 --- a/packages/tasks/tasks-local/package.json +++ b/packages/tasks/tasks-local/package.json @@ -30,7 +30,7 @@ "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -40,6 +40,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index c85c3af713..7978944edb 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-tasks-local */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeLayer } from '@deepseek-ai/dsh-scope' diff --git a/packages/tasks/tasks-local/src/invariant.ts b/packages/tasks/tasks-local/src/invariant.ts index 22f2b4fecd..21d00ed155 100644 --- a/packages/tasks/tasks-local/src/invariant.ts +++ b/packages/tasks/tasks-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tasks-local' diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 2c3fd11ef2..b64f2142aa 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json index eea8e2af6b..61b8cd1ab8 100644 --- a/packages/tasks/tasks/package.json +++ b/packages/tasks/tasks/package.json @@ -29,13 +29,13 @@ "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" } } diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 8c49ec8445..126ba075ff 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-tasks */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { TaskDoneListener, TaskId, TaskRead, TaskSnapshot, TaskStart } from './types.ts' @@ -23,7 +23,7 @@ export type { TaskStatus, } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { tasks: TaskService } diff --git a/packages/tasks/tasks/src/invariant.ts b/packages/tasks/tasks/src/invariant.ts index a633213607..99f850e929 100644 --- a/packages/tasks/tasks/src/invariant.ts +++ b/packages/tasks/tasks/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned background-task snapshot invariants. @module @deepseek-ai/dsh-tasks/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { TaskSnapshot } from './types.ts' diff --git a/packages/tasks/tasks/tests/invariant.spec.ts b/packages/tasks/tasks/tests/invariant.spec.ts index e23609df5d..db7ca8bb0e 100644 --- a/packages/tasks/tasks/tests/invariant.spec.ts +++ b/packages/tasks/tasks/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks' diff --git a/packages/tasks/tasks/tests/service.spec.ts b/packages/tasks/tasks/tests/service.spec.ts index 9c6e2654e3..73b0a3fe25 100644 --- a/packages/tasks/tasks/tests/service.spec.ts +++ b/packages/tasks/tasks/tests/service.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import 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' diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json index dfffbf4855..38827df4f8 100644 --- a/packages/tasks/tool-tasks/package.json +++ b/packages/tasks/tool-tasks/package.json @@ -25,7 +25,7 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", @@ -35,7 +35,7 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -47,6 +47,6 @@ "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" } } diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index 720cb51f97..2ec5bdaff7 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-tool-tasks */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { TextRetainer } from '@deepseek-ai/dsh-retention' import { defineTool } from '@deepseek-ai/dsh-tools' diff --git a/packages/tasks/tool-tasks/src/invariant.ts b/packages/tasks/tool-tasks/src/invariant.ts index cedad9dc1c..d1c9f77bd6 100644 --- a/packages/tasks/tool-tasks/src/invariant.ts +++ b/packages/tasks/tool-tasks/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-tasks' diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index a213ca83e5..3221c20246 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 2c4490589f..ee57660440 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -30,7 +30,7 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "^3.18.0", "zod": "^4.4.3" }, "peerDependencies": { @@ -39,11 +39,11 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-projection": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -55,6 +55,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 12e0d164c6..e92af70313 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-tool-todo */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { ZodType } from 'zod' import { defineTool } from '@deepseek-ai/dsh-tools' diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index 8c2a7aa7dd..f1b8c63066 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned durable todo-snapshot invariants. @module @deepseek-ai/dsh-tool-todo/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 568b0f764a..264731bdbc 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index ac3c76bf71..c38958df95 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import ToolRegistry from '@deepseek-ai/dsh-tools' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index 572254e348..41728d93e1 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -6,9 +6,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index 0b214fc311..b53e9dd587 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -8,7 +8,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 12d1f5f665..5e79b1a69a 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 5ffb933214..fd9f14c449 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -35,13 +35,13 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "zod": "^4.4.3" } } diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index e5ade888cf..358edceb66 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -653,7 +653,7 @@ class FaceAnalyzer { for (const statement of sourceFile.statements) { if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name) - || statement.name.text !== 'cordis' + || statement.name.text !== '@deepseek-ai/cordis' || statement.body === undefined || !ts.isModuleBlock(statement.body)) continue for (const member of statement.body.statements) { @@ -2545,7 +2545,7 @@ function sourceFileHasSurface(sourceFile: ts.SourceFile): boolean { } if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name) - || statement.name.text !== 'cordis' + || statement.name.text !== '@deepseek-ai/cordis' || statement.body === undefined || !ts.isModuleBlock(statement.body)) continue if (statement.body.statements.some(member => ts.isInterfaceDeclaration(member) diff --git a/packages/typert/generator/src/invariant.ts b/packages/typert/generator/src/invariant.ts index e4da20785f..1c153f74f4 100644 --- a/packages/typert/generator/src/invariant.ts +++ b/packages/typert/generator/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-typert-generator' diff --git a/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap b/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap index bcc28cd8b2..da7510736b 100644 --- a/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap +++ b/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap @@ -407,7 +407,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/host:packages/host/src/index.ts#Agent#id@480", + "id": "@fixture/host:packages/host/src/index.ts#Agent#id@493", "kind": "property", "location": { "column": 3, @@ -426,7 +426,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/host:packages/host/src/index.ts#Agent#state@502", + "id": "@fixture/host:packages/host/src/index.ts#Agent#state@515", "kind": "property", "location": { "column": 3, @@ -446,7 +446,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Read the public display label.", - "id": "@fixture/host:packages/host/src/index.ts#Agent#label@735", + "id": "@fixture/host:packages/host/src/index.ts#Agent#label@748", "jsDoc": "/** Read the public display label. */", "kind": "getter", "location": { @@ -472,7 +472,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Accept a public display label.", - "id": "@fixture/host:packages/host/src/index.ts#Agent#label@823", + "id": "@fixture/host:packages/host/src/index.ts#Agent#label@836", "jsDoc": "/** Accept a public display label. */", "kind": "setter", "location": { @@ -507,7 +507,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Run one typed input.", - "id": "@fixture/host:packages/host/src/index.ts#Agent#run@902", + "id": "@fixture/host:packages/host/src/index.ts#Agent#run@915", "jsDoc": "/** Run one typed input. */", "kind": "method", "location": { @@ -598,7 +598,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Report readiness.", - "id": "@fixture/host:packages/host/src/index.ts#AliasedService#ready@1181", + "id": "@fixture/host:packages/host/src/index.ts#AliasedService#ready@1194", "jsDoc": "/** Report readiness. */", "kind": "method", "location": { @@ -651,7 +651,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Report readiness.", - "id": "@fixture/host:packages/host/src/index.ts#DefaultOnlyService#ready@1404", + "id": "@fixture/host:packages/host/src/index.ts#DefaultOnlyService#ready@1417", "jsDoc": "/** Report readiness. */", "kind": "method", "location": { @@ -704,7 +704,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Inspect one agent without flattening its generic state.", - "id": "@fixture/host:packages/host/src/index.ts#DemoService#inspect@1767", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#inspect@1780", "jsDoc": "/** Inspect one agent without flattening its generic state. */", "kind": "method", "location": { @@ -747,7 +747,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Keep an npm-owned type as External.", - "id": "@fixture/host:packages/host/src/index.ts#DemoService#acceptsExternal@1965", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#acceptsExternal@1978", "jsDoc": "/** Keep an npm-owned type as External. */", "kind": "method", "location": { @@ -782,7 +782,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Accept a developer-authored enum without flattening it.", - "id": "@fixture/host:packages/host/src/index.ts#DemoService#setPhase@2102", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#setPhase@2115", "jsDoc": "/** Accept a developer-authored enum without flattening it. */", "kind": "method", "location": { @@ -817,7 +817,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Exercise every retained type-graph shape from a public boundary.", - "id": "@fixture/host:packages/host/src/index.ts#DemoService#inspectSyntax@2234", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#inspectSyntax@2247", "jsDoc": "/** Exercise every retained type-graph shape from a public boundary. */", "kind": "method", "location": { @@ -852,7 +852,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": true, "description": "Preserve async source metadata without changing its type signature.", - "id": "@fixture/host:packages/host/src/index.ts#DemoService#inspectAsync@2369", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#inspectAsync@2382", "jsDoc": "/** Preserve async source metadata without changing its type signature. */", "kind": "method", "location": { @@ -887,7 +887,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Retain an authored binding-pattern parameter.", - "id": "@fixture/host:packages/host/src/index.ts#DemoService#destructure@2496", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#destructure@2509", "jsDoc": "/** Retain an authored binding-pattern parameter. */", "kind": "method", "location": { @@ -2816,7 +2816,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/host/src/index.ts:116:31#1#ready@3038", + "id": "type:packages/host/src/index.ts:116:31#1#ready@3064", "kind": "property", "location": { "column": 33, @@ -2919,7 +2919,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/host/src/index.ts:12:43#1#ready@387", + "id": "type:packages/host/src/index.ts:12:43#1#ready@400", "kind": "property", "location": { "column": 45, @@ -3037,7 +3037,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/host/src/index.ts:139:31#1#ready@3476", + "id": "type:packages/host/src/index.ts:139:31#1#ready@3515", "kind": "property", "location": { "column": 33, @@ -3207,7 +3207,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "name": "Service", "target": { "kind": "external", - "module": "cordis", + "module": "@deepseek-ai/cordis", "name": "Service", "subpath": ".", }, @@ -3224,7 +3224,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "name": "Service", "target": { "kind": "external", - "module": "cordis", + "module": "@deepseek-ai/cordis", "name": "Service", "subpath": ".", }, @@ -3241,7 +3241,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "name": "Service", "target": { "kind": "external", - "module": "cordis", + "module": "@deepseek-ai/cordis", "name": "Service", "subpath": ".", }, @@ -3265,7 +3265,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/host/src/index.ts:68:24#1#ready@1790", + "id": "type:packages/host/src/index.ts:68:24#1#ready@1803", "kind": "property", "location": { "column": 26, @@ -5711,7 +5711,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "line": 101, }, "members": [ - "@fixture/host:packages/host/src/index.ts#AliasedService#ready@1181", + "@fixture/host:packages/host/src/index.ts#AliasedService#ready@1194", ], "summary": "Service exported only through a non-default alias.", "symbol": "@fixture/host:packages/host/src/index.ts#AliasedService", @@ -5736,7 +5736,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "line": 102, }, "members": [ - "@fixture/host:packages/host/src/index.ts#DefaultOnlyService#ready@1404", + "@fixture/host:packages/host/src/index.ts#DefaultOnlyService#ready@1417", ], "summary": "Service exported only through the package default.", "symbol": "@fixture/host:packages/host/src/index.ts#DefaultOnlyService", @@ -5760,12 +5760,12 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "line": 100, }, "members": [ - "@fixture/host:packages/host/src/index.ts#DemoService#inspect@1767", - "@fixture/host:packages/host/src/index.ts#DemoService#acceptsExternal@1965", - "@fixture/host:packages/host/src/index.ts#DemoService#setPhase@2102", - "@fixture/host:packages/host/src/index.ts#DemoService#inspectSyntax@2234", - "@fixture/host:packages/host/src/index.ts#DemoService#inspectAsync@2369", - "@fixture/host:packages/host/src/index.ts#DemoService#destructure@2496", + "@fixture/host:packages/host/src/index.ts#DemoService#inspect@1780", + "@fixture/host:packages/host/src/index.ts#DemoService#acceptsExternal@1978", + "@fixture/host:packages/host/src/index.ts#DemoService#setPhase@2115", + "@fixture/host:packages/host/src/index.ts#DemoService#inspectSyntax@2247", + "@fixture/host:packages/host/src/index.ts#DemoService#inspectAsync@2382", + "@fixture/host:packages/host/src/index.ts#DemoService#destructure@2509", ], "summary": "Fixture service with generic, mapped, and truly external boundary types.", "symbol": "@fixture/host:packages/host/src/index.ts#DemoService", @@ -5827,7 +5827,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Return the host-owned object unchanged.", - "id": "@fixture/client:packages/client/src/index.ts#ClientBridge#reflect@1096", + "id": "@fixture/client:packages/client/src/index.ts#ClientBridge#reflect@1109", "jsDoc": "/** Return the host-owned object unchanged. */", "kind": "method", "location": { @@ -5888,7 +5888,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#agent@587", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#agent@600", "kind": "property", "location": { "column": 3, @@ -5907,7 +5907,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#inherited@632", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#inherited@645", "kind": "property", "location": { "column": 3, @@ -5926,7 +5926,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#importedAgent@666", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#importedAgent@679", "kind": "property", "location": { "column": 3, @@ -5945,7 +5945,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#importedAgentWithNamedArgument@739", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#importedAgentWithNamedArgument@752", "kind": "property", "location": { "column": 3, @@ -5964,7 +5964,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#namespaceAgent@821", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#namespaceAgent@834", "kind": "property", "location": { "column": 3, @@ -5983,7 +5983,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#defaultService@876", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#defaultService@889", "kind": "property", "location": { "column": 3, @@ -6002,7 +6002,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#payload@915", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#payload@928", "kind": "property", "location": { "column": 3, @@ -6021,7 +6021,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#phase@943", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#phase@956", "kind": "property", "location": { "column": 3, @@ -6084,7 +6084,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/client/src/index.ts:11:48#1#ready@469", + "id": "type:packages/client/src/index.ts:11:48#1#ready@482", "kind": "property", "location": { "column": 50, @@ -6130,7 +6130,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/client/src/index.ts:15:29#1#ready@615", + "id": "type:packages/client/src/index.ts:15:29#1#ready@628", "kind": "property", "location": { "column": 31, @@ -6188,7 +6188,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/client/src/index.ts:17:57#1#ready@722", + "id": "type:packages/client/src/index.ts:17:57#1#ready@735", "kind": "property", "location": { "column": 59, @@ -6264,7 +6264,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/client/src/index.ts:19:39#1#ready@859", + "id": "type:packages/client/src/index.ts:19:39#1#ready@872", "kind": "property", "location": { "column": 41, @@ -6334,7 +6334,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "name": "Service", "target": { "kind": "external", - "module": "cordis", + "module": "@deepseek-ai/cordis", "name": "Service", "subpath": ".", }, @@ -6371,7 +6371,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/client/src/index.ts:28:40#1#ready@1135", + "id": "type:packages/client/src/index.ts:28:40#1#ready@1148", "kind": "property", "location": { "column": 42, @@ -6477,7 +6477,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "line": 35, }, "members": [ - "@fixture/client:packages/client/src/index.ts#ClientBridge#reflect@1096", + "@fixture/client:packages/client/src/index.ts#ClientBridge#reflect@1109", ], "summary": "Client-face service.", "symbol": "@fixture/client:packages/client/src/index.ts#ClientBridge", diff --git a/packages/typert/generator/tests/cordis-catalog-contract.spec.ts b/packages/typert/generator/tests/cordis-catalog-contract.spec.ts index bde52505dc..e7d4acfba1 100644 --- a/packages/typert/generator/tests/cordis-catalog-contract.spec.ts +++ b/packages/typert/generator/tests/cordis-catalog-contract.spec.ts @@ -91,7 +91,7 @@ function fixtureRoot(eventsBlock: string): string { const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-')) writeProject( root, - `declare module 'cordis' {\n interface Events {\n${eventsBlock}\n }\n}\n`, + `declare module '@deepseek-ai/cordis' {\n interface Events {\n${eventsBlock}\n }\n}\n`, ) return root } @@ -103,7 +103,7 @@ function serviceFixtureRoot(classSource: string): string { const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-')) writeProject( root, - `declare module 'cordis' {\n interface Context {\n fix: FixService\n }\n}\n\n${classSource}\n`, + `declare module '@deepseek-ai/cordis' {\n interface Context {\n fix: FixService\n }\n}\n\n${classSource}\n`, ) return root } diff --git a/packages/typert/generator/tests/fixtures/type-model/cordis.d.ts b/packages/typert/generator/tests/fixtures/type-model/cordis.d.ts index 970e8a5dda..fb59bcae21 100644 --- a/packages/typert/generator/tests/fixtures/type-model/cordis.d.ts +++ b/packages/typert/generator/tests/fixtures/type-model/cordis.d.ts @@ -1,4 +1,4 @@ -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { export class Service { protected readonly __service?: never } export interface Context {} diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/client/src/index.ts b/packages/typert/generator/tests/fixtures/type-model/packages/client/src/index.ts index 82080a344e..61dc98ba2f 100644 --- a/packages/typert/generator/tests/fixtures/type-model/packages/client/src/index.ts +++ b/packages/typert/generator/tests/fixtures/type-model/packages/client/src/index.ts @@ -1,4 +1,4 @@ -import { Service } from 'cordis' +import { Service } from '@deepseek-ai/cordis' import type HostDefault from '@fixture/host' import type * as Host from '@fixture/host' import type { AgentPhase } from '@fixture/host' @@ -30,7 +30,7 @@ export class ClientBridge extends Service { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { clientBridge: ClientBridge } diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/host/src/index.ts b/packages/typert/generator/tests/fixtures/type-model/packages/host/src/index.ts index bb73873699..463c4dafc7 100644 --- a/packages/typert/generator/tests/fixtures/type-model/packages/host/src/index.ts +++ b/packages/typert/generator/tests/fixtures/type-model/packages/host/src/index.ts @@ -1,4 +1,4 @@ -import { Service } from 'cordis' +import { Service } from '@deepseek-ai/cordis' import type { ZodType } from 'zod' import type { AgentPhase, Box, Entity, Flags, Payload, Present, SyntaxZoo } from './models.ts' @@ -95,7 +95,7 @@ export class DemoService extends Service { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { demo: DemoService aliased: AliasedService @@ -130,7 +130,7 @@ declare module 'cordis' { type IgnoredDeclaration = string } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { demo: DemoService } diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/write/src/index.ts b/packages/typert/generator/tests/fixtures/type-model/packages/write/src/index.ts index 290e3944a3..ea6cc55308 100644 --- a/packages/typert/generator/tests/fixtures/type-model/packages/write/src/index.ts +++ b/packages/typert/generator/tests/fixtures/type-model/packages/write/src/index.ts @@ -1,4 +1,4 @@ -import { Service } from 'cordis' +import { Service } from '@deepseek-ai/cordis' /** Service whose public annotations are intentionally absent. */ export class WritableService extends Service { @@ -9,7 +9,7 @@ export class WritableService extends Service { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { writable: WritableService } diff --git a/packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json b/packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json index 3885bac238..8c0dc65be8 100644 --- a/packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json +++ b/packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json @@ -11,7 +11,7 @@ "ignoreDeprecations": "6.0", "types": ["node"], "paths": { - "cordis": ["./cordis.d.ts"], + "@deepseek-ai/cordis": ["./cordis.d.ts"], "@fixture/host": ["./packages/host/src/index.ts"], "@fixture/host/*": ["./packages/host/src/*"], "@fixture/client": ["./packages/client/src/index.ts"], diff --git a/packages/typert/generator/tests/tools-catalog.spec.ts b/packages/typert/generator/tests/tools-catalog.spec.ts index 95c1ab09de..4bb51e89a9 100644 --- a/packages/typert/generator/tests/tools-catalog.spec.ts +++ b/packages/typert/generator/tests/tools-catalog.spec.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { TypertContribution } from '@deepseek-ai/dsh-typert-registry/types' import { EVENT_API, SERVICE_API, TYPE_API } from '@deepseek-ai/dsh-tool-cordis/src/api-catalog.ts' diff --git a/packages/typert/generator/tests/type-model.spec.ts b/packages/typert/generator/tests/type-model.spec.ts index 81e7f0b981..6911659222 100644 --- a/packages/typert/generator/tests/type-model.spec.ts +++ b/packages/typert/generator/tests/type-model.spec.ts @@ -834,7 +834,7 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) writeFileSync(join(hostRoot, 'src/index.ts'), [ 'export {}', - "declare module 'cordis' {", + "declare module '@deepseek-ai/cordis' {", ' interface Context {}', ' interface Events {}', ' interface Ignored {}', @@ -1237,10 +1237,10 @@ function configureDualRuntimeClient(root: string, splitProjects: boolean): void } writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) writeFileSync(join(packageRoot, 'src/client.ts'), [ - "import { Service } from 'cordis'", + "import { Service } from '@deepseek-ai/cordis'", 'export interface ClientOnlyMarker { readonly client: true }', 'export class BrowserBridge extends Service {}', - "declare module 'cordis' { interface Context { browserBridge: BrowserBridge } }", + "declare module '@deepseek-ai/cordis' { interface Context { browserBridge: BrowserBridge } }", '', ].join('\n')) const indexPath = join(packageRoot, 'src/index.ts') @@ -1338,14 +1338,14 @@ function addExplicitServicePackage(root: string, annotation: string, withProtoco ' /** Report protocol readiness. */', ' ready(): boolean', '}', - "declare module 'cordis' {", + "declare module '@deepseek-ai/cordis' {", ' interface Context { detached: DetachedProtocol }', '}', '', ].join('\n')) } writeFileSync(join(packageRoot, 'src/index.ts'), [ - "import { Service } from 'cordis'", + "import { Service } from '@deepseek-ai/cordis'", ...(withProtocol ? ["export type { DetachedProtocol } from './types.ts'"] : []), '/**', ' * Service implementation discovered independently of its protocol package.', diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index ca6db771e3..998c0ec3a5 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -25,19 +25,19 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@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", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "zod": "^4.4.3" } } diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index 2ae9722dea..7cb73c446f 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -29,9 +29,9 @@ import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' -import type { Context } from 'cordis' -import z from 'schemastery' -import type {} from '@cordisjs/plugin-loader' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-typert-registry' import type { TypertContribution } from '@deepseek-ai/dsh-typert-registry/types' diff --git a/packages/typert/loader/src/invariant.ts b/packages/typert/loader/src/invariant.ts index 393324e7e9..dc7070c6cc 100644 --- a/packages/typert/loader/src/invariant.ts +++ b/packages/typert/loader/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-typert-loader' diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 491538a533..ac14d39be5 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -4,8 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as typertLoader from '@deepseek-ai/dsh-typert-loader' import { validateTypertManifest } from '@deepseek-ai/dsh-typert-loader' diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index f094a0c11c..dacb88764b 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -51,10 +51,10 @@ }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/typert/registry/src/client/index.ts b/packages/typert/registry/src/client/index.ts index e468e78999..1eb4b1246d 100644 --- a/packages/typert/registry/src/client/index.ts +++ b/packages/typert/registry/src/client/index.ts @@ -1,6 +1,6 @@ /** Browser face of the shared TypeRT runtime registry. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { TypertRegistry } from '../service.ts' /** Required services: none; this is the Client reflection root. */ diff --git a/packages/typert/registry/src/invariant.ts b/packages/typert/registry/src/invariant.ts index 73b01a6742..93c63f786c 100644 --- a/packages/typert/registry/src/invariant.ts +++ b/packages/typert/registry/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-typert-registry' diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 353ad4c9e0..b8bab3a121 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-typert-registry */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { z } from 'zod' import type { InvocationDescriptor, diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 92b81803c7..1bc4b65cb1 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import TypertRegistry, { typertEndpoint, diff --git a/packages/typert/type-meta/package.json b/packages/typert/type-meta/package.json index e2d7689866..9a1c362780 100644 --- a/packages/typert/type-meta/package.json +++ b/packages/typert/type-meta/package.json @@ -31,10 +31,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 1375d7872b..40a8b5b2ad 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-type-meta */ -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' import type { TypeRTContextMap } from './types.ts' const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ diff --git a/packages/typert/type-meta/src/invariant.ts b/packages/typert/type-meta/src/invariant.ts index 22dc290a1e..304f0f9ac4 100644 --- a/packages/typert/type-meta/src/invariant.ts +++ b/packages/typert/type-meta/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-type-meta' diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index c1d6b3dcf9..622698bf6c 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-type-meta/types */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' declare const LOOKUP_HOST: unique symbol declare const LOOKUP_WIRE: unique symbol @@ -421,7 +421,7 @@ export interface TypeRTService { readonly contexts: TypeRTContextRegistry } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { typert: TypeRTService } diff --git a/packages/typert/type-meta/tests/fixtures/source-launch.ts b/packages/typert/type-meta/tests/fixtures/source-launch.ts index 14eec6610d..55bea062ae 100644 --- a/packages/typert/type-meta/tests/fixtures/source-launch.ts +++ b/packages/typert/type-meta/tests/fixtures/source-launch.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { GatewayService, Remote, diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index bfe99630b9..aa77541128 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -1,6 +1,6 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { bindTypeRTGateway, diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index 00de333170..b54b5989cf 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -26,10 +26,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/util/atomic-write/src/invariant.ts b/packages/util/atomic-write/src/invariant.ts index 4027dd9bda..241d439cdf 100644 --- a/packages/util/atomic-write/src/invariant.ts +++ b/packages/util/atomic-write/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-atomic-write' diff --git a/packages/util/atomic-write/tests/invariant.spec.ts b/packages/util/atomic-write/tests/invariant.spec.ts index c80346762c..ab77662f47 100644 --- a/packages/util/atomic-write/tests/invariant.spec.ts +++ b/packages/util/atomic-write/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import * as AtomicWriteInvariant from '../src/invariant.ts' diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 83676d1d5c..534cb20049 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -26,10 +26,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/util/brand/src/invariant.ts b/packages/util/brand/src/invariant.ts index bf29a81b4c..cd33bf10cb 100644 --- a/packages/util/brand/src/invariant.ts +++ b/packages/util/brand/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-brand' diff --git a/packages/util/environment/README.i18n.yaml b/packages/util/environment/README.i18n.yaml index c8e36d8a5e..17b9c8d879 100644 --- a/packages/util/environment/README.i18n.yaml +++ b/packages/util/environment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/environment/README.md -README.md: 599a9ba747905b66452b57717dabcc6f4678a3dd -README.zh.md: d728f835b1ece44d2848a618e504951f26fec7e2 +README.md: cdbc5b6dd4a5ea90c323f570598e2db124b43857 +README.zh.md: bbef1719370c787763eaa686a5609ec31833386e diff --git a/packages/util/environment/README.md b/packages/util/environment/README.md index 599a9ba747..cdbc5b6dd4 100644 --- a/packages/util/environment/README.md +++ b/packages/util/environment/README.md @@ -21,7 +21,7 @@ Values do also reach `process.env` — a user's `--config` tree and third-party Names match the way the platform matches them: exactly on POSIX, case-insensitively on Windows. A case-sensitive lookup there would rank the wrong layer — a shell's `deepseek_api_key` and a project `.env`'s `DEEPSEEK_API_KEY` are one variable to the OS, and treating them as two would let the project win. ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' declare const ctx: Context diff --git a/packages/util/environment/README.zh.md b/packages/util/environment/README.zh.md index d728f835b1..bbef171937 100644 --- a/packages/util/environment/README.zh.md +++ b/packages/util/environment/README.zh.md @@ -21,7 +21,7 @@ 变量名按平台自身的规则匹配:POSIX 上精确匹配,Windows 上不区分大小写。在 Windows 上做大小写敏感的查找会选错层——shell 里的 `deepseek_api_key` 与项目 `.env` 里的 `DEEPSEEK_API_KEY` 对操作系统而言是同一个变量,把它们当成两个就会让项目胜出。 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' declare const ctx: Context diff --git a/packages/util/environment/package.json b/packages/util/environment/package.json index 15cbb603a5..113f3b10c9 100644 --- a/packages/util/environment/package.json +++ b/packages/util/environment/package.json @@ -26,10 +26,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/util/environment/src/index.ts b/packages/util/environment/src/index.ts index 63a59c7337..d40cbc3c3b 100644 --- a/packages/util/environment/src/index.ts +++ b/packages/util/environment/src/index.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-environment */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' /** * Which layer supplied a value, from most to least trusted: the environment @@ -116,7 +116,7 @@ export function environmentOf(ctx: Context): EnvironmentSnapshot { ?? createEnvironmentSnapshot([{ source: 'process', values: process.env as Record<string, string> }]) } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Launcher-owned snapshot of this run's environment; absent in compositions the product CLI did not boot. */ launcherEnvironment?: EnvironmentSnapshot diff --git a/packages/util/environment/src/invariant.ts b/packages/util/environment/src/invariant.ts index 96e53828ae..f5ca3b698c 100644 --- a/packages/util/environment/src/invariant.ts +++ b/packages/util/environment/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-environment' diff --git a/packages/util/environment/tests/environment.spec.ts b/packages/util/environment/tests/environment.spec.ts index 5951484a83..4ae4e93d8a 100644 --- a/packages/util/environment/tests/environment.spec.ts +++ b/packages/util/environment/tests/environment.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, environmentOf, } from '../src/index.ts' diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index d282a128f7..a50e0f2c12 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -26,10 +26,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/util/native-command/src/invariant.ts b/packages/util/native-command/src/invariant.ts index bec1d4b774..0504f06e3e 100644 --- a/packages/util/native-command/src/invariant.ts +++ b/packages/util/native-command/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-native-command' diff --git a/packages/util/paths/package.json b/packages/util/paths/package.json index cece1ce79e..749d3e7ac9 100644 --- a/packages/util/paths/package.json +++ b/packages/util/paths/package.json @@ -26,10 +26,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" } } diff --git a/packages/util/paths/src/invariant.ts b/packages/util/paths/src/invariant.ts index f1661b7f52..92a636e57b 100644 --- a/packages/util/paths/src/invariant.ts +++ b/packages/util/paths/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-paths' diff --git a/packages/util/retention/package.json b/packages/util/retention/package.json index 80af828263..ad61896d35 100644 --- a/packages/util/retention/package.json +++ b/packages/util/retention/package.json @@ -26,10 +26,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "^4.0.0-rc.6" } } diff --git a/packages/util/retention/src/invariant.ts b/packages/util/retention/src/invariant.ts index 0365793b03..90f7cf5252 100644 --- a/packages/util/retention/src/invariant.ts +++ b/packages/util/retention/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-retention' diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 853cee4d79..0a398aa7a4 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -26,10 +26,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/util/timeout/src/invariant.ts b/packages/util/timeout/src/invariant.ts index bb9604d6b7..1284ecf8da 100644 --- a/packages/util/timeout/src/invariant.ts +++ b/packages/util/timeout/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-timeout' diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index d5d655b2dd..585c5a0e83 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -30,11 +30,11 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.67", - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "^3.18.0", "turndown": "^7.2.4" }, "devDependencies": { @@ -51,6 +51,6 @@ "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-fetch-local": "workspace:^", "@deepseek-ai/dsh-web-search-exa": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 94130df697..b28af9fa0a 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -5,7 +5,7 @@ * signal. A provider timeout remains a backstop for direct service callers. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import TurndownService from 'turndown' import { gfm } from '@joplin/turndown-plugin-gfm' import { defineTool } from '@deepseek-ai/dsh-tools' diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index aad4d8728c..b9bd0ddcc8 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-tool-web */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-web' import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts' import { applyWebFetchTool } from './fetch.ts' diff --git a/packages/web/tool-web/src/invariant.ts b/packages/web/tool-web/src/invariant.ts index 435f9ca549..a9ea21c3a9 100644 --- a/packages/web/tool-web/src/invariant.ts +++ b/packages/web/tool-web/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-web' diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index 35c97dfda2..e550d10096 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -5,7 +5,7 @@ * never provider selection or network access. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, JsonValue, ToolResult, WebSearchResultView, WebSource } from '@deepseek-ai/dsh-tools' import type { WebSearchResult, WebSearchSource } from '@deepseek-ai/dsh-web' diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts index e235d4d455..8c64ced81a 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { AddressInfo } from 'node:net' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools' diff --git a/packages/web/tool-web/tests/load-path.spec.ts b/packages/web/tool-web/tests/load-path.spec.ts index 1df718b871..32b1fe06fc 100644 --- a/packages/web/tool-web/tests/load-path.spec.ts +++ b/packages/web/tool-web/tests/load-path.spec.ts @@ -6,8 +6,8 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import WebService from '@deepseek-ai/dsh-web' diff --git a/packages/web/tool-web/tests/spill.spec.ts b/packages/web/tool-web/tests/spill.spec.ts index 33279fc9e9..1f972360b8 100644 --- a/packages/web/tool-web/tests/spill.spec.ts +++ b/packages/web/tool-web/tests/spill.spec.ts @@ -13,7 +13,7 @@ import { AddressInfo } from 'node:net' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index de9bf79284..781209a9cc 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import TurndownService from 'turndown' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/web/web-fetch-local/package.json b/packages/web/web-fetch-local/package.json index e6dc5791d9..cc34f6535d 100644 --- a/packages/web/web-fetch-local/package.json +++ b/packages/web/web-fetch-local/package.json @@ -28,15 +28,15 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/web/web-fetch-local/src/index.ts b/packages/web/web-fetch-local/src/index.ts index a5636b37ee..92fe6c3025 100644 --- a/packages/web/web-fetch-local/src/index.ts +++ b/packages/web/web-fetch-local/src/index.ts @@ -7,8 +7,8 @@ * @module @deepseek-ai/dsh-web-fetch-local */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-web' import { LocalFetchProvider } from './provider.ts' import type { LocalFetchLimits } from './provider.ts' diff --git a/packages/web/web-fetch-local/src/invariant.ts b/packages/web/web-fetch-local/src/invariant.ts index 053fb12200..13f1990549 100644 --- a/packages/web/web-fetch-local/src/invariant.ts +++ b/packages/web/web-fetch-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-fetch-local' diff --git a/packages/web/web-fetch-local/tests/fetch-local.spec.ts b/packages/web/web-fetch-local/tests/fetch-local.spec.ts index 092e384d94..8200d0fe1d 100644 --- a/packages/web/web-fetch-local/tests/fetch-local.spec.ts +++ b/packages/web/web-fetch-local/tests/fetch-local.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { AddressInfo } from 'node:net' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import WebService from '@deepseek-ai/dsh-web' import { LocalFetchProvider, LOCAL_FETCH_PROVIDER_ID } from '@deepseek-ai/dsh-web-fetch-local' import type { LocalFetchLimits } from '@deepseek-ai/dsh-web-fetch-local' diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 0ca52390de..cee385654b 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -31,10 +31,10 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -44,6 +44,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index 5e55e12457..8b465d9617 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-web-search-deepseek */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { environmentOf } from '@deepseek-ai/dsh-environment' diff --git a/packages/web/web-search-deepseek/src/invariant.ts b/packages/web/web-search-deepseek/src/invariant.ts index d1f707f2bc..1f0b59d9e0 100644 --- a/packages/web/web-search-deepseek/src/invariant.ts +++ b/packages/web/web-search-deepseek/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-deepseek' diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index 23c2d2c237..af51213660 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -2,8 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { credentialRef } from '@deepseek-ai/dsh-credentials' import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' import WebService from '@deepseek-ai/dsh-web' diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index c2d55e00ff..1861465ce9 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -28,15 +28,15 @@ "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/web/web-search-exa/src/index.ts b/packages/web/web-search-exa/src/index.ts index 2ecb71336a..0b37cd5c1a 100644 --- a/packages/web/web-search-exa/src/index.ts +++ b/packages/web/web-search-exa/src/index.ts @@ -8,9 +8,9 @@ * @module @deepseek-ai/dsh-web-search-exa */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-web' import { ExaSearchProvider, diff --git a/packages/web/web-search-exa/src/invariant.ts b/packages/web/web-search-exa/src/invariant.ts index 060ceb78ba..d7ba293ef1 100644 --- a/packages/web/web-search-exa/src/invariant.ts +++ b/packages/web/web-search-exa/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-exa' diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts index 6e29b10aa8..86c20a3ffb 100644 --- a/packages/web/web-search-exa/tests/exa.spec.ts +++ b/packages/web/web-search-exa/tests/exa.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import WebService from '@deepseek-ai/dsh-web' import { ExaSearchProvider, EXA_PROVIDER_ID } from '@deepseek-ai/dsh-web-search-exa' import * as exaPlugin from '@deepseek-ai/dsh-web-search-exa' diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 1ece514da9..9c821c8dd7 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -28,15 +28,15 @@ "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/web/web-search-perplexity/src/index.ts b/packages/web/web-search-perplexity/src/index.ts index e1fe6a2606..e3ac97605d 100644 --- a/packages/web/web-search-perplexity/src/index.ts +++ b/packages/web/web-search-perplexity/src/index.ts @@ -7,9 +7,9 @@ * @module @deepseek-ai/dsh-web-search-perplexity */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-web' import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL } from './provider.ts' diff --git a/packages/web/web-search-perplexity/src/invariant.ts b/packages/web/web-search-perplexity/src/invariant.ts index cf3e009fed..fa4d1981b6 100644 --- a/packages/web/web-search-perplexity/src/invariant.ts +++ b/packages/web/web-search-perplexity/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-perplexity' diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts index b622342384..d12c3957ca 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.spec.ts +++ b/packages/web/web-search-perplexity/tests/perplexity.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import WebService from '@deepseek-ai/dsh-web' import { PerplexitySearchProvider, diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 4ea964ee93..4d4f787412 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -27,14 +27,14 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts index ded152cc81..eeadfaed77 100644 --- a/packages/web/web/src/index.ts +++ b/packages/web/web/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-web */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { WebFetchProvider, WebFetchRequest, @@ -32,7 +32,7 @@ export type { WebSearchSource, } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { web: WebService } diff --git a/packages/web/web/src/invariant.ts b/packages/web/web/src/invariant.ts index 2ac094b34f..4ec2462d13 100644 --- a/packages/web/web/src/invariant.ts +++ b/packages/web/web/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web' diff --git a/packages/web/web/tests/web.spec.ts b/packages/web/web/tests/web.spec.ts index 978284ee51..7fcb3b1f28 100644 --- a/packages/web/web/tests/web.spec.ts +++ b/packages/web/web/tests/web.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import WebService, { WebError, type WebFetchProvider, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index 83bf0d057c..c2ebc18c31 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -32,13 +32,13 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-workflow": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -52,6 +52,6 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/workflow/tool-ralph/src/index.ts b/packages/workflow/tool-ralph/src/index.ts index e6e7edfccf..86e7d28843 100644 --- a/packages/workflow/tool-ralph/src/index.ts +++ b/packages/workflow/tool-ralph/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-tool-ralph */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-session' import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' diff --git a/packages/workflow/tool-ralph/src/invariant.ts b/packages/workflow/tool-ralph/src/invariant.ts index 22a7d1f2ea..3b050136fb 100644 --- a/packages/workflow/tool-ralph/src/invariant.ts +++ b/packages/workflow/tool-ralph/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-ralph' diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index eb379dde16..d05d011a41 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts index 29ba7edd4e..7033b89bd8 100644 --- a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts +++ b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import type { Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 705e4f6e08..0078621f99 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -31,10 +31,10 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-workflow": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -46,6 +46,6 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 6c1e9b19bb..af9aeed5e8 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -10,8 +10,8 @@ * @module @deepseek-ai/dsh-tool-workflow */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index 5f3ebc68ce..74edbb21dc 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-workflow' diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index a61862cffd..687fd675c7 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index e019bcaa51..e229c02e37 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -38,10 +38,10 @@ "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-workflow": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -56,7 +56,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "tsx": "^4.19.2" } } diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 57570a5098..501a3555c3 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -9,7 +9,7 @@ import { Worker } from 'node:worker_threads' import type { WorkerOptions } from 'node:worker_threads' import { fileURLToPath } from 'node:url' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { assertNever } from '@deepseek-ai/dsh-llm' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' diff --git a/packages/workflow/workflow-workerthread/src/index.ts b/packages/workflow/workflow-workerthread/src/index.ts index 33c5917acf..309da3af97 100644 --- a/packages/workflow/workflow-workerthread/src/index.ts +++ b/packages/workflow/workflow-workerthread/src/index.ts @@ -9,8 +9,8 @@ import { randomUUID } from 'node:crypto' import { availableParallelism } from 'node:os' import * as vm from 'node:vm' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import WorkflowService, { WorkflowError, WorkflowRunId } from '@deepseek-ai/dsh-workflow' import type { WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' import { WorkerRun } from './host.ts' diff --git a/packages/workflow/workflow-workerthread/src/invariant.ts b/packages/workflow/workflow-workerthread/src/invariant.ts index 6845aeebff..401292691a 100644 --- a/packages/workflow/workflow-workerthread/src/invariant.ts +++ b/packages/workflow/workflow-workerthread/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-workflow-workerthread' diff --git a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts index e330b3ee83..7b94e48b96 100644 --- a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts @@ -21,7 +21,7 @@ describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built work const driver = join(packageRoot, `.built-worker-driver-${process.pid}.mjs`) try { await writeFile(driver, ` -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SubagentService from '@deepseek-ai/dsh-subagent' import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' diff --git a/packages/workflow/workflow-workerthread/tests/integration.spec.ts b/packages/workflow/workflow-workerthread/tests/integration.spec.ts index e137d7f94f..fe71dcf750 100644 --- a/packages/workflow/workflow-workerthread/tests/integration.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/integration.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts index 3fb08bb5ba..7eecf1a52b 100644 --- a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts @@ -5,7 +5,7 @@ */ import { expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts index 08e1845d22..5c6128cb38 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index de190349a4..8b580d8ce5 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { fileURLToPath } from 'node:url' import type { Worker } from 'node:worker_threads' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 53ef7e6f6e..3cc199028a 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -30,7 +30,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -38,6 +38,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 526da15ad8..380729d148 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-workflow */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { WorkflowAgentEndInfo, @@ -30,7 +30,7 @@ export type { WorkflowStopReason, } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { workflows: WorkflowService } diff --git a/packages/workflow/workflow/src/invariant.ts b/packages/workflow/workflow/src/invariant.ts index f6b8b8ced2..47dc82d0bf 100644 --- a/packages/workflow/workflow/src/invariant.ts +++ b/packages/workflow/workflow/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned workflow lifecycle invariants. @module @deepseek-ai/dsh-workflow/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { WorkflowAgentEndInfo, diff --git a/packages/workflow/workflow/tests/invariant.spec.ts b/packages/workflow/workflow/tests/invariant.spec.ts index 671a7a3e86..54eed758fa 100644 --- a/packages/workflow/workflow/tests/invariant.spec.ts +++ b/packages/workflow/workflow/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SessionId } from '@deepseek-ai/dsh-session' import { WorkflowRunId } from '@deepseek-ai/dsh-workflow' import type { diff --git a/packages/workflow/workflow/tests/workflow.spec.ts b/packages/workflow/workflow/tests/workflow.spec.ts index 1cdf15c26d..689d959c9c 100644 --- a/packages/workflow/workflow/tests/workflow.spec.ts +++ b/packages/workflow/workflow/tests/workflow.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import WorkflowServiceDefault, { isFatalWorkflowError, WorkflowError, diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 7839b29702..b33296c228 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -36,7 +36,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-storage": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "zod": "^4.4.3" @@ -48,6 +48,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index d2d20d65ab..d972085939 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -8,7 +8,7 @@ import { randomUUID } from 'node:crypto' import { stat } from 'node:fs/promises' import { basename } from 'node:path' -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-persistence' import type { DomainGlobal, KvTable } from '@deepseek-ai/dsh-storage-domain' @@ -53,7 +53,7 @@ export class WorkspaceUnknownSessionError extends Error { } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { workspace: WorkspaceRegistry } diff --git a/packages/workspace/workspace/src/invariant.ts b/packages/workspace/workspace/src/invariant.ts index 808ce1dedf..abcc61bca1 100644 --- a/packages/workspace/workspace/src/invariant.ts +++ b/packages/workspace/workspace/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-workspace/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import { WorkspaceId } from '@deepseek-ai/dsh-workspace' diff --git a/packages/workspace/workspace/tests/invariant.spec.ts b/packages/workspace/workspace/tests/invariant.spec.ts index 0d0556a44a..803a7dfe4b 100644 --- a/packages/workspace/workspace/tests/invariant.spec.ts +++ b/packages/workspace/workspace/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import * as WorkspaceInvariant from '../src/invariant.ts' diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 6a562e15bd..3c4b6185fb 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import Storage from '@deepseek-ai/dsh-storage' import type { StorageBackend } from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 732e00e4ee..77c29b881b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,16 +122,19 @@ importers: apps/cli: dependencies: - '@cordisjs/plugin-hmr': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../vendor/cordis + '@deepseek-ai/cordis-plugin-hmr': specifier: workspace:* version: link:../../vendor/hmr - '@cordisjs/plugin-include': + '@deepseek-ai/cordis-plugin-include': specifier: workspace:* version: link:../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:* version: link:../../vendor/loader - '@cordisjs/plugin-timer': + '@deepseek-ai/cordis-plugin-timer': specifier: workspace:* version: link:../../vendor/timer '@deepseek-ai/dsh-agent-tool-mode': @@ -272,9 +275,6 @@ importers: commander: specifier: ^15.0.0 version: 15.0.0 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../vendor/cordis js-yaml: specifier: ^4.2.0 version: 4.2.0 @@ -337,7 +337,7 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) devDependencies: - '@cordisjs/plugin-group': + '@deepseek-ai/cordis-plugin-group': specifier: workspace:^ version: link:../../vendor/group '@deepseek-ai/dsh-client-modules': @@ -382,16 +382,16 @@ importers: examples: dependencies: - '@cordisjs/plugin-hmr': + '@deepseek-ai/cordis-plugin-hmr': specifier: workspace:* version: link:../vendor/hmr - '@cordisjs/plugin-include': + '@deepseek-ai/cordis-plugin-include': specifier: workspace:* version: link:../vendor/include - '@cordisjs/plugin-logger-console': + '@deepseek-ai/cordis-plugin-logger-console': specifier: workspace:* version: link:../vendor/logger-console - '@cordisjs/plugin-timer': + '@deepseek-ai/cordis-plugin-timer': specifier: workspace:* version: link:../vendor/timer '@deepseek-ai/dsh-acp-demo': @@ -734,10 +734,13 @@ importers: '@agentclientprotocol/sdk': specifier: 0.25.1 version: 0.25.1(zod@4.4.3) - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.17.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -762,9 +765,6 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/api/gateway: dependencies: @@ -772,6 +772,9 @@ importers: specifier: workspace:^ version: link:../../typert/type-meta devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection @@ -784,9 +787,6 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis zod: specifier: ^4.4.3 version: 4.4.3 @@ -797,6 +797,9 @@ importers: specifier: workspace:^ version: link:../../typert/type-meta devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -815,31 +818,31 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/attachment/attachment: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/attachment/attachment-local: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery sharp: specifier: ^0.35.3 version: 0.35.3(@types/node@22.20.0) devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../attachment @@ -849,12 +852,12 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/bash: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -864,16 +867,16 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/bash-env: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -895,16 +898,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/bash-local: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash @@ -920,12 +923,12 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/bash-sandbox: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash @@ -950,16 +953,16 @@ importers: '@deepseek-ai/node-addon-landlock-run': specifier: workspace:* version: link:../../../native/landlock-run/packages/entry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/pwsh-local: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash @@ -975,12 +978,12 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/pwsh-sandbox: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash @@ -1002,16 +1005,16 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/tool-bash: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -1069,16 +1072,16 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/tool-pwsh: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -1127,9 +1130,6 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/boot/app-boot: dependencies: @@ -1137,19 +1137,22 @@ importers: specifier: ^4.2.0 version: 4.2.0 devDependencies: - '@cordisjs/plugin-group': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-group': specifier: workspace:^ version: link:../../../vendor/group - '@cordisjs/plugin-hmr': + '@deepseek-ai/cordis-plugin-hmr': specifier: workspace:^ version: link:../../../vendor/hmr - '@cordisjs/plugin-include': + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@cordisjs/plugin-timer': + '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer '@deepseek-ai/dsh-environment': @@ -1167,16 +1170,13 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bundle/base: dependencies: - '@cordisjs/plugin-hmr': + '@deepseek-ai/cordis-plugin-hmr': specifier: workspace:* version: link:../../../vendor/hmr - '@cordisjs/plugin-timer': + '@deepseek-ai/cordis-plugin-timer': specifier: workspace:* version: link:../../../vendor/timer '@deepseek-ai/dsh-agent': @@ -1405,23 +1405,26 @@ importers: specifier: workspace:^ version: link:../../context/workspace-context devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bundle/headless: dependencies: '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -1439,9 +1442,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bundle/web-app: dependencies: @@ -1568,10 +1568,13 @@ importers: '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash-env': specifier: workspace:^ version: link:../../bash/bash-env @@ -1581,9 +1584,6 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/connection: dependencies: @@ -1605,13 +1605,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery ws: specifier: ^8.21.0 version: 8.21.0 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -1621,17 +1624,17 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/hmr: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-client-modules': @@ -1643,9 +1646,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/locale: dependencies: @@ -1655,10 +1655,13 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -1674,16 +1677,16 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 packages/client/modules: devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-host-webserver': @@ -1692,9 +1695,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/runtime: dependencies: @@ -1750,6 +1750,9 @@ importers: specifier: ~4.4.7 version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1762,28 +1765,25 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery packages/client/schema-form: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/test-runtime: dependencies: @@ -1797,6 +1797,9 @@ importers: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -1818,9 +1821,6 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1830,6 +1830,9 @@ importers: packages/client/ui-agent-preset: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -1863,9 +1866,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1876,6 +1876,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -1906,9 +1909,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1918,13 +1918,16 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery clsx: specifier: ^2.0.0 version: 2.1.1 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -1994,9 +1997,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2007,6 +2007,9 @@ importers: specifier: ^18.2.0 version: 18.3.1 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2028,12 +2031,12 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/ui-goal: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -2067,9 +2070,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2079,6 +2079,9 @@ importers: packages/client/ui-layout: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2097,15 +2100,15 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-model: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2139,15 +2142,15 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-models: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2181,15 +2184,15 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-permission: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2226,15 +2229,15 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-plan: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2268,9 +2271,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2338,6 +2338,9 @@ importers: specifier: ^4.3.1 version: 4.3.1 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2347,9 +2350,6 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/ui-question: dependencies: @@ -2375,6 +2375,9 @@ importers: specifier: ^18.2.0 version: 18.3.1 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -2396,9 +2399,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/ui-settings: dependencies: @@ -2406,6 +2406,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2430,9 +2433,6 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2445,10 +2445,13 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2479,9 +2482,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2492,6 +2492,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2516,15 +2519,15 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-skill: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2558,9 +2561,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2574,6 +2574,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2595,24 +2598,21 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-slots: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/ui-subagent: dependencies: @@ -2620,6 +2620,9 @@ importers: specifier: ^18.2.0 version: 18.3.1 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2653,9 +2656,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/ui-theme: dependencies: @@ -2665,13 +2665,16 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery clsx: specifier: ^2.0.0 version: 2.1.1 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2693,9 +2696,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2706,6 +2706,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2739,9 +2742,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2758,6 +2758,9 @@ importers: specifier: ^9.0.0 version: 9.0.0 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -2779,9 +2782,6 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2795,6 +2795,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2822,9 +2825,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2856,7 +2856,10 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-client-runtime': @@ -2874,9 +2877,6 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis typescript: specifier: ^6.0.3 version: 6.0.3 @@ -2893,31 +2893,34 @@ importers: specifier: 1.2.0 version: 1.2.0(react@18.3.1) devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/code-runtime/code-runtime: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/code-runtime/code-runtime-worker: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ version: link:../code-runtime @@ -2930,16 +2933,16 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/compact/command-compact: devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -2960,12 +2963,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/compact/compact: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -2981,20 +2984,20 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/compact/compact-basic: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -3033,20 +3036,20 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/compact/compact-tool-result-prune: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-compact': @@ -3064,16 +3067,16 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/context/session-reference: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -3095,16 +3098,16 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/context/time-context: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -3132,16 +3135,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/context/tmux-context: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -3160,17 +3163,17 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/context/workspace-context: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.6 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -3209,12 +3212,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/core/agent: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -3236,16 +3239,16 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/core/agent-default-model: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent @@ -3258,16 +3261,16 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/core/agent-loop: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent @@ -3295,16 +3298,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/core/agent-tool-mode: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent @@ -3326,21 +3329,21 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/core/scope: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/core/session: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -3359,16 +3362,16 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/core/system-prompt: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -3378,16 +3381,16 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../scope - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/core/tools: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent @@ -3412,34 +3415,34 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/credentials/credentials: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/credentials/credentials-local: dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery chokidar: specifier: ^4.0.3 version: 4.0.3 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery yaml: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ version: link:../../util/atomic-write @@ -3455,19 +3458,19 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/e2b/e2b: dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery e2b: specifier: 2.29.1 version: 2.29.1 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -3477,12 +3480,12 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/e2b/fs-e2b: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-e2b': specifier: workspace:^ version: link:../e2b @@ -3492,16 +3495,16 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/e2b/subprocess-e2b: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-e2b': specifier: workspace:^ version: link:../e2b @@ -3514,16 +3517,16 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/examples/acp-demo: devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-acp': @@ -3562,20 +3565,20 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../context/workspace-context - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.17.0 version: link:../../../vendor/schemastery packages/examples/agent-spine-demo: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-timer': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer '@deepseek-ai/dsh-agent': @@ -3677,9 +3680,6 @@ importers: '@deepseek-ai/node-addon-landlock-run': specifier: workspace:* version: link:../../../native/landlock-run/packages/entry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/examples/jsonrpc-demo: dependencies: @@ -3687,19 +3687,22 @@ importers: specifier: workspace:^ version: link:../../boot/app-boot devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/feedback/command-feedback: devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -3720,12 +3723,12 @@ importers: '@deepseek-ai/dsh-user-id': specifier: workspace:^ version: link:../../session/user-id - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/fs/fs: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -3738,19 +3741,19 @@ importers: '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/fs/fs-local: dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery koffi: specifier: ^3.1.0 version: 3.1.1 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs @@ -3760,12 +3763,12 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/fs/fs-policy: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs @@ -3775,12 +3778,12 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/fs/fs-sandbox: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs @@ -3796,19 +3799,19 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/fs/tool-fs: dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery diff: specifier: ^9.0.0 version: 9.0.0 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -3854,19 +3857,19 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/fs/tool-fs-search: dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery '@vscode/ripgrep': specifier: ^1.18.0 version: 1.18.0 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.6 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -3900,16 +3903,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/fs/tool-str-replace-editor: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -3946,13 +3949,13 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/goal/command-goal: devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -3973,19 +3976,19 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/goal/goal: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.17.2 version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4013,12 +4016,12 @@ importers: '@deepseek-ai/dsh-type-meta': specifier: workspace:^ version: link:../../typert/type-meta - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/goal/goal-session: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4046,17 +4049,17 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/goal/tool-goal: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -4080,16 +4083,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/guard/repeat-tool-guard: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4111,12 +4114,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/guard/timeout-policy: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4129,12 +4132,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/hooks/hook-protocol: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash @@ -4144,16 +4147,16 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/hooks/hooks-claude: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4196,16 +4199,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/hooks/hooks-codex: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4245,9 +4248,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/host/apiproxy: dependencies: @@ -4323,13 +4323,16 @@ importers: '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets @@ -4348,25 +4351,25 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/host/directory-picker: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/host/directory-picker-auto: devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-host-directory-picker': @@ -4384,22 +4387,22 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/host/directory-picker-browse: dependencies: '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery clsx: specifier: ^2.0.0 version: 2.1.1 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../../client/locale @@ -4424,9 +4427,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -4443,6 +4443,9 @@ importers: specifier: ^3.1.0 version: 3.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../../client/runtime @@ -4458,9 +4461,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -4470,11 +4470,14 @@ importers: packages/host/frontend-static: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-host-webserver': @@ -4483,25 +4486,25 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/host/webserver: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/interaction/commands: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4517,19 +4520,19 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/interaction/permission: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash @@ -4557,12 +4560,12 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/interaction/tool-ask-user: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4581,16 +4584,16 @@ importers: '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../user-interaction - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/interaction/user-approval: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4612,12 +4615,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/interaction/user-interaction: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4627,16 +4630,16 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/llm/llm: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment @@ -4649,19 +4652,19 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/llm/llm-deepseek: dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery eventsource-parser: specifier: ^3.1.0 version: 3.1.0 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials @@ -4680,19 +4683,19 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/llm/llm-pi-ai: dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery '@earendil-works/pi-ai': specifier: ^0.82.1 version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment @@ -4717,20 +4720,20 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/llm/llm-retry: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -4775,19 +4778,19 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/llm/token-meter: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../../compact/compact @@ -4803,12 +4806,12 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session/session-projection - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/lsp/lsp: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -4818,16 +4821,16 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/lsp/lsp-local: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -4855,9 +4858,6 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis typescript: specifier: ^6.0.3 version: 6.0.3 @@ -4867,10 +4867,13 @@ importers: packages/lsp/tool-lsp: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4907,22 +4910,22 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/mcp/mcp-client: dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery '@modelcontextprotocol/sdk': specifier: ^1.12.0 version: 1.29.0(zod@4.4.3) - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4941,9 +4944,6 @@ importers: '@modelcontextprotocol/server-filesystem': specifier: ^2026.7.4 version: 2026.7.10(zod@4.4.3) - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/plan/plan-mode: dependencies: @@ -4951,6 +4951,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4984,23 +4987,23 @@ importers: '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../interaction/user-interaction - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/preset/agent-presets: dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery js-yaml: specifier: ^4.1.0 version: 4.2.0 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -5039,16 +5042,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/preset/persona: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5058,12 +5061,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/pty/pty: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5076,16 +5079,16 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/pty/pty-local: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5110,20 +5113,20 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/pty/tool-bash-persistent: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -5162,20 +5165,20 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/pty/tool-pty: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -5223,12 +5226,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/sandbox/sandbox: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5238,9 +5241,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/sandbox/sandbox-local: dependencies: @@ -5250,10 +5250,13 @@ importers: '@deepseek-ai/node-addon-landlock-run': specifier: workspace:* version: link:../../../native/landlock-run/packages/entry - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5266,16 +5269,16 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/sandbox/sandbox-policy: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5291,9 +5294,6 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/sandbox/sandbox-windows-acl: dependencies: @@ -5301,6 +5301,9 @@ importers: specifier: ^3.1.0 version: 3.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5310,12 +5313,12 @@ importers: '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../sandbox-local - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/scaffold/client: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5328,9 +5331,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/scaffold/create-sdk: dependencies: @@ -5341,12 +5341,12 @@ importers: specifier: ^15.0.0 version: 15.0.0 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/scaffold/helper: dependencies: @@ -5366,6 +5366,9 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -5396,12 +5399,12 @@ importers: '@deepseek-ai/dsh-tool-web': specifier: workspace:^ version: link:../../web/tool-web - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/scaffold/protocol: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5414,9 +5417,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/scaffold/scripts: dependencies: @@ -5433,15 +5433,15 @@ importers: specifier: ^0.1.4 version: 0.1.4 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis tsdown: specifier: ^0.22.2 version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3) @@ -5451,11 +5451,14 @@ importers: packages/scaffold/server: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.17.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -5488,9 +5491,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/scaffold/telemetry: dependencies: @@ -5498,6 +5498,9 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -5507,20 +5510,20 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/self-modification/tool-cordis: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: ^1.0.0-rc.5 version: link:../../../vendor/loader - '@cordisjs/plugin-timer': + '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer '@deepseek-ai/dsh-agent': @@ -5550,12 +5553,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session-query/session-query: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -5574,17 +5577,17 @@ importers: '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session-query/session-query-sqlite: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-invariants': @@ -5602,16 +5605,16 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../session-query - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session-query/tool-session-query: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5651,13 +5654,13 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-checkpoint-policy: devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -5690,12 +5693,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-persistence: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -5711,19 +5714,19 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-persistence-jsonl: dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery koffi: specifier: ^3.1.0 version: 3.1.1 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5733,16 +5736,16 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../session-persistence - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-persistence-sqlite: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5752,9 +5755,6 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../session-persistence - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-projection: dependencies: @@ -5762,25 +5762,28 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-projection-cache: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5799,12 +5802,12 @@ importers: '@deepseek-ai/dsh-storage-domain': specifier: workspace:^ version: link:../../storage/storage-domain - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-telemetry: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5814,12 +5817,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-telemetry-otel: dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery '@opentelemetry/api': specifier: ^1.9.1 version: 1.9.1 @@ -5838,11 +5841,11 @@ importers: '@opentelemetry/sdk-logs': specifier: ^0.220.0 version: 0.220.0(@opentelemetry/api@1.9.1) - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-command-feedback': @@ -5863,19 +5866,19 @@ importers: '@deepseek-ai/dsh-user-id': specifier: workspace:^ version: link:../user-id - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-title: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -5897,16 +5900,16 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../session-projection - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-title-all-messages-llm: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5922,20 +5925,20 @@ importers: '@deepseek-ai/dsh-session-title-llm': specifier: workspace:^ version: link:../session-title-llm - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-title-first-message-llm: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-invariants': @@ -5956,16 +5959,16 @@ importers: '@deepseek-ai/dsh-session-title-llm': specifier: workspace:^ version: link:../session-title-llm - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-title-llm: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5981,12 +5984,12 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/user-id: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -5996,37 +5999,37 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/settings/settings: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery packages/settings/settings-local: dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery chokidar: specifier: ^4.0.3 version: 4.0.3 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery yaml: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ version: link:../../util/atomic-write @@ -6039,16 +6042,16 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../settings - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/skill/skill: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6058,34 +6061,34 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/skill/skill-badge: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/skill/skill-local: dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery chokidar: specifier: ^5.0.0 version: 5.0.0 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery yaml: specifier: ^2.4.2 version: 2.9.0 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs @@ -6098,16 +6101,16 @@ importers: '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/skill/tool-skill: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6132,12 +6135,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/spill/spill: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.6 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -6150,16 +6153,16 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/spill/spill-local: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.6 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -6175,16 +6178,16 @@ importers: '@deepseek-ai/dsh-spill': specifier: workspace:^ version: link:../spill - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/spill/spill-policy: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.6 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6209,69 +6212,66 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/storage/storage: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/storage/storage-domain: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-storage': specifier: workspace:^ version: link:../storage - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/storage/storage-json: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-storage': specifier: workspace:^ version: link:../storage - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/storage/storage-sqlite: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-storage': specifier: workspace:^ version: link:../storage - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/subagent: dependencies: @@ -6279,6 +6279,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6321,20 +6324,20 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/subagent-acp: dependencies: '@agentclientprotocol/sdk': specifier: 0.25.1 version: 0.25.1(zod@4.4.3) - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: ^1.0.0-rc.5 version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -6364,9 +6367,6 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/subagent-claude-code: dependencies: @@ -6376,10 +6376,13 @@ importers: '@anthropic-ai/sdk': specifier: 0.93.0 version: 0.93.0(zod@4.4.3) - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6407,17 +6410,17 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/subagent-codex: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: ^1.0.0-rc.5 version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -6453,17 +6456,17 @@ importers: '@openai/codex': specifier: 0.147.0 version: 0.147.0 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/subagent-dsh-sdk: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: ^1.0.0-rc.5 version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -6493,17 +6496,17 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/subagent-fork: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: ^1.0.0-rc.5 version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -6533,16 +6536,16 @@ importers: '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:^ version: link:../subagent-spawn - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/subagent-inprocess: devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: ^1.0.4 version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: ^1.0.0-rc.5 version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -6587,17 +6590,17 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/subagent-spawn: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: ^1.0.0-rc.5 version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -6639,17 +6642,17 @@ importers: '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../tool-subagent - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/tool-subagent: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: ^1.0.0-rc.5 version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -6691,12 +6694,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/tool-subagent-control: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6733,16 +6736,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/tool-subagent-report: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6779,18 +6782,15 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subprocess/subprocess: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subprocess/subprocess-local: dependencies: @@ -6798,6 +6798,9 @@ importers: specifier: ^1.1.0 version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6807,9 +6810,6 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/support/acp-snapshot: dependencies: @@ -6823,18 +6823,21 @@ importers: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/support/agent-loop-testkit: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6856,31 +6859,31 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/support/invariants: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - cordis: + '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis packages/support/llm-mock-server: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/support/llm-replay: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../../compact/compact @@ -6893,9 +6896,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/support/loader-smoke: dependencies: @@ -6906,6 +6906,9 @@ importers: specifier: ^4.22.4 version: 4.22.4 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.6 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6918,12 +6921,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/tasks/tasks: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.6 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6936,12 +6939,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/tasks/tasks-local: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6963,16 +6966,16 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/tasks/tool-tasks: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.6 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7000,23 +7003,23 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/todo/tool-todo: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -7052,9 +7055,6 @@ importers: '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../interaction/user-interaction - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/typert/generator: dependencies: @@ -7065,6 +7065,9 @@ importers: specifier: ^6.0.3 version: 6.0.3 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -7074,20 +7077,20 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis zod: specifier: ^4.4.3 version: 4.4.3 packages/typert/loader: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-invariants': @@ -7096,9 +7099,6 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis zod: specifier: ^4.4.3 version: 4.4.3 @@ -7112,97 +7112,100 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/typert/type-meta: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/util/atomic-write: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/util/brand: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/util/environment: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/util/native-command: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/util/paths: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.6 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/util/retention: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.6 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/util/timeout: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/web/tool-web: dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../../../vendor/schemastery '@joplin/turndown-plugin-gfm': specifier: ^1.0.67 version: 1.0.67 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery turndown: specifier: ^7.2.4 version: 7.2.4 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7242,32 +7245,32 @@ importers: '@types/turndown': specifier: ^5.0.6 version: 5.0.6 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/web/web: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/web/web-fetch-local: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -7277,16 +7280,16 @@ importers: '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/web/web-search-deepseek: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7308,16 +7311,16 @@ importers: '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/web/web-search-exa: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-environment': specifier: workspace:^ version: link:../../util/environment @@ -7327,16 +7330,16 @@ importers: '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/web/web-search-perplexity: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-environment': specifier: workspace:^ version: link:../../util/environment @@ -7346,17 +7349,17 @@ importers: '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/workflow/tool-ralph: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -7398,16 +7401,16 @@ importers: '@deepseek-ai/dsh-workflow-workerthread': specifier: workspace:^ version: link:../workflow-workerthread - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/workflow/tool-workflow: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7435,12 +7438,12 @@ importers: '@deepseek-ai/dsh-workflow-workerthread': specifier: workspace:^ version: link:../workflow-workerthread - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/workflow/workflow: devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7456,16 +7459,16 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/workflow/workflow-workerthread: dependencies: - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7502,9 +7505,6 @@ importers: '@deepseek-ai/dsh-workflow': specifier: workspace:^ version: link:../workflow - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis tsx: specifier: ^4.19.2 version: 4.22.4 @@ -7515,6 +7515,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -7533,22 +7536,22 @@ importers: '@deepseek-ai/dsh-storage-domain': specifier: workspace:^ version: link:../../storage/storage-domain - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis python/sdk-runtime: dependencies: - '@cordisjs/plugin-group': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../vendor/cordis + '@deepseek-ai/cordis-plugin-group': specifier: workspace:^ version: link:../../vendor/group - '@cordisjs/plugin-include': + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../vendor/loader - '@cordisjs/plugin-timer': + '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../vendor/timer '@deepseek-ai/dsh-acp': @@ -7845,62 +7848,59 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../packages/context/workspace-context - cordis: - specifier: workspace:^ - version: link:../../vendor/cordis - schemastery: + '@deepseek-ai/schemastery': specifier: workspace:^ version: link:../../vendor/schemastery vendor/cordis: dependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis-plugin-include': specifier: ^1.0.4 version: link:../include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: ^1.0.0-rc.5 version: link:../loader + '@deepseek-ai/cosmokit': + specifier: ^1.8.1 + version: link:../cosmokit '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 - cosmokit: - specifier: ^1.8.1 - version: link:../cosmokit vendor/cosmokit: {} vendor/group: dependencies: - '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.5 - version: link:../loader - cordis: + '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 version: link:../cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: ^1.0.0-rc.5 + version: link:../loader vendor/hmr: dependencies: '@babel/code-frame': specifier: ^7.29.0 version: 7.29.7 - '@cordisjs/plugin-timer': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../cordis + '@deepseek-ai/cordis-plugin-timer': specifier: ^1.1.2 version: link:../timer + '@deepseek-ai/cosmokit': + specifier: ^1.8.1 + version: link:../cosmokit + '@deepseek-ai/schemastery': + specifier: ^3.18.0 + version: link:../schemastery chokidar: specifier: ^4.0.3 version: 4.0.3 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../cordis - cosmokit: - specifier: ^1.8.1 - version: link:../cosmokit picomatch: specifier: ^4.0.3 version: 4.0.4 - schemastery: - specifier: ^3.18.0 - version: link:../schemastery devDependencies: '@types/babel__code-frame': specifier: ^7.27.0 @@ -7914,13 +7914,13 @@ importers: vendor/include: dependencies: - '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.5 - version: link:../loader - cordis: + '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 version: link:../cordis - cosmokit: + '@deepseek-ai/cordis-plugin-loader': + specifier: ^1.0.0-rc.5 + version: link:../loader + '@deepseek-ai/cosmokit': specifier: ^1.8.1 version: link:../cosmokit js-yaml: @@ -7929,10 +7929,10 @@ importers: vendor/loader: dependencies: - cordis: + '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 version: link:../cordis - cosmokit: + '@deepseek-ai/cosmokit': specifier: ^1.8.1 version: link:../cosmokit node-addon-require-builtin: @@ -7941,13 +7941,13 @@ importers: vendor/logger-console: dependencies: - cordis: + '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 version: link:../cordis - cosmokit: + '@deepseek-ai/cosmokit': specifier: ^1.8.1 version: link:../cosmokit - schemastery: + '@deepseek-ai/schemastery': specifier: ^3.18.0 version: link:../schemastery supports-color: @@ -7956,19 +7956,19 @@ importers: vendor/schemastery: dependencies: + '@deepseek-ai/cosmokit': + specifier: ^1.8.1 + version: link:../cosmokit '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 - cosmokit: - specifier: ^1.8.1 - version: link:../cosmokit vendor/timer: dependencies: - cordis: + '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 version: link:../cordis - cosmokit: + '@deepseek-ai/cosmokit': specifier: ^1.8.1 version: link:../cosmokit diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 66510d89ec..65208d7da6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -51,10 +51,6 @@ allowBuilds: '@deepseek-ai/dsh-pty-local@file:packages/pty/pty-local': true minimumReleaseAgeExclude: - # Cordis release candidates are source-vendored and pinned in vendor/README.md - # during the same-day sync that updates package manifests and the lockfile. - - '@cordisjs/plugin-loader@1.0.0-rc.5' - - cordis@4.0.0-rc.7 # Fresh pi-ai releases carry the model catalog updates that are the whole # point of bumping it; waiting out the release age would defeat that. - '@earendil-works/pi-ai@0.82.1' diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index d5e90fb1b9..04a182d727 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "dependencies": { - "@cordisjs/plugin-group": "workspace:^", - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", - "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/cordis-plugin-group": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", @@ -107,7 +107,7 @@ "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", - "cordis": "workspace:^", - "schemastery": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index c38197c1e3..bb06639e65 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -21,15 +21,15 @@ const workspaceGlobs = [ { dir: 'apps', depth: 1 }, ] as const const vendoredPackages = new Set([ - 'cordis', - 'cosmokit', - 'schemastery', - '@cordisjs/plugin-loader', - '@cordisjs/plugin-include', - '@cordisjs/plugin-group', - '@cordisjs/plugin-timer', - '@cordisjs/plugin-hmr', - '@cordisjs/plugin-logger-console', + '@deepseek-ai/cordis', + '@deepseek-ai/cosmokit', + '@deepseek-ai/schemastery', + '@deepseek-ai/cordis-plugin-loader', + '@deepseek-ai/cordis-plugin-include', + '@deepseek-ai/cordis-plugin-group', + '@deepseek-ai/cordis-plugin-timer', + '@deepseek-ai/cordis-plugin-hmr', + '@deepseek-ai/cordis-plugin-logger-console', ]) const publicLandlockPackages = new Set([ '@deepseek-ai/node-addon-landlock-run', @@ -271,13 +271,13 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { } if (dir.startsWith('packages/') && manifest.name?.startsWith('@deepseek-ai/dsh-')) { - const peer = manifest.peerDependencies?.cordis - const dev = manifest.devDependencies?.cordis + const peer = manifest.peerDependencies?.['@deepseek-ai/cordis'] + const dev = manifest.devDependencies?.['@deepseek-ai/cordis'] - if (!peer) errors.push(`${label}: cordis must be a peerDependency`) - if (!dev) errors.push(`${label}: cordis must also be a devDependency`) + if (!peer) errors.push(`${label}: @deepseek-ai/cordis must be a peerDependency`) + if (!dev) errors.push(`${label}: @deepseek-ai/cordis must also be a devDependency`) if (peer && dev && peer !== dev) { - errors.push(`${label}: cordis peer (${peer}) and dev (${dev}) ranges must match`) + errors.push(`${label}: @deepseek-ai/cordis peer (${peer}) and dev (${dev}) ranges must match`) } if (manifest.version !== repositoryVersion) { errors.push(`${label}: package.json version must match root version ${repositoryVersion ?? '(missing)'}`) diff --git a/scripts/cordis-walk.ts b/scripts/cordis-walk.ts index befc6156e1..3a38f0ca29 100644 --- a/scripts/cordis-walk.ts +++ b/scripts/cordis-walk.ts @@ -11,12 +11,12 @@ import ts from 'typescript' /** Cheap textual prefilter for a cordis module merge, quote-style agnostic * (the AST match below reads `stmt.name.text` and never sees the quotes). */ -const MERGE_HEAD = /declare module ['"](?:cordis|\.\/context\.ts)['"]/ +const MERGE_HEAD = /declare module ['"](?:@deepseek-ai\/cordis|\.\/context\.ts)['"]/ /** * Parse every file matching `patterns` (repo-relative, sorted, `/`-normalized) * that textually contains a cordis module merge, yielding one entry per merge - * BLOCK — a file may legally hold several `declare module 'cordis'` blocks + * BLOCK — a file may legally hold several `declare module '@deepseek-ai/cordis'` blocks * (the Typert analyzer reads them all), so the exhaustiveness scan must too. * Files without a merge are skipped. * @param scanRoot - Repository root the patterns are resolved against. @@ -39,14 +39,14 @@ export function contextMergeFiles( return out } -/** Every cordis module-merge body in `sf`: `declare module 'cordis'` (harness +/** Every cordis module-merge body in `sf`: `declare module '@deepseek-ai/cordis'` (harness * packages) or `declare module './context.ts'` (vendor core), in source order. * Module-local: consumers walk blocks through {@link contextMergeFiles}. */ function cordisModuleBodies(sf: ts.SourceFile): ts.ModuleBlock[] { const bodies: ts.ModuleBlock[] = [] for (const stmt of sf.statements) { if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue - if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue + if (stmt.name.text !== '@deepseek-ai/cordis' && stmt.name.text !== './context.ts') continue if (stmt.body && ts.isModuleBlock(stmt.body)) bodies.push(stmt.body) } return bodies @@ -60,7 +60,7 @@ export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null { } /** - * Every `key: Type` property a `declare module 'cordis'` Context merge + * Every `key: Type` property a `declare module '@deepseek-ai/cordis'` Context merge * declares in one module body. * @param body - The cordis module augmentation block. * @param sf - Owning source file (for text extraction). @@ -79,7 +79,7 @@ export function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<stri } /** - * Every event name a `declare module 'cordis'` Events merge declares in one + * Every event name a `declare module '@deepseek-ai/cordis'` Events merge declares in one * module body. Names are the literal member keys (`'agent/created'`), read * from method and property members alike so a declaration form the projector * would reject still enters the exhaustiveness scan. diff --git a/scripts/gen-cordis-catalog-partition.spec.ts b/scripts/gen-cordis-catalog-partition.spec.ts index 2c6ef2584d..1e9007ccf6 100644 --- a/scripts/gen-cordis-catalog-partition.spec.ts +++ b/scripts/gen-cordis-catalog-partition.spec.ts @@ -128,7 +128,7 @@ describe('cordis-walk scan reach', () => { const dir = join(root, 'packages/client/ui-x/src/client') mkdirSync(dir, { recursive: true }) writeFileSync(join(dir, 'index.ts'), [ - "declare module 'cordis' {", + "declare module '@deepseek-ai/cordis' {", ' interface Events {', " 'x/changed'(): void", ' }', @@ -153,12 +153,12 @@ describe('cordis-walk scan reach', () => { // backstop must not stop at the first one, skip the double-quoted legal // form, or ignore .tsx sources. writeFileSync(join(dir, 'split.ts'), [ - "declare module 'cordis' {", + "declare module '@deepseek-ai/cordis' {", ' interface Context {', ' first: FirstService', ' }', '}', - 'declare module "cordis" {', + 'declare module "@deepseek-ai/cordis" {', ' interface Events {', " 'second/changed'(): void", ' }', @@ -167,7 +167,7 @@ describe('cordis-walk scan reach', () => { '', ].join('\n')) writeFileSync(join(dir, 'view.tsx'), [ - "declare module 'cordis' {", + "declare module '@deepseek-ai/cordis' {", ' interface Context {', ' fromTsx: TsxService', ' }', @@ -189,7 +189,7 @@ describe('cordis-walk scan reach', () => { it('reads string-literal and identifier member names from an Events merge', () => { const sf = ts.createSourceFile('x.ts', [ - "declare module 'cordis' {", + "declare module '@deepseek-ai/cordis' {", ' interface Events {', " 'scope/list'(items: string[]): void", ' plain(): void', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 885e0cf9d2..7ee9320cea 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -99,7 +99,7 @@ export const SERVICE_PAGE: Record<string, string> = { /** * Context keys declared in `interface Context` merges that the rendering * projection cannot see, each with the reason and its documentation owner. - * The scan that enforces this list reads EVERY `declare module 'cordis'` + * The scan that enforces this list reads EVERY `declare module '@deepseek-ai/cordis'` * Context merge under `packages/x/x/src/**` — any depth, not only root * `index.ts` files with a same-named service class — so a new service can * never silently join this blind spot: it either enters {@link SERVICE_PAGE} @@ -168,7 +168,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = { * Event names declared in `interface Events` merges that the rendering * projection cannot see, each with the reason and its documentation owner. * The mirror of {@link SERVICE_WALK_EXEMPTIONS} for events: an independent - * scan reads EVERY `declare module 'cordis'` Events merge under + * scan reads EVERY `declare module '@deepseek-ai/cordis'` Events merge under * `packages/x/x/src/**`, so a declared event either renders onto a subsystems * page (via {@link EVENT_SCOPE_PAGE}) or names itself here — never vanishes * silently. Keys are full event names, not scopes: client-face events share diff --git a/scripts/gen-scoped-events.ts b/scripts/gen-scoped-events.ts index ead001a79b..9d070def72 100644 --- a/scripts/gen-scoped-events.ts +++ b/scripts/gen-scoped-events.ts @@ -309,14 +309,14 @@ class ScopedEventGenerator { } } -/** Return whether an Events interface is inside declare module 'cordis'. */ +/** Return whether an Events interface is inside declare module '@deepseek-ai/cordis'. */ function isCordisModuleInterface(node: ts.InterfaceDeclaration): boolean { const block = node.parent const declaration = block.parent return ts.isModuleBlock(block) && ts.isModuleDeclaration(declaration) && ts.isStringLiteral(declaration.name) - && declaration.name.text === 'cordis' + && declaration.name.text === '@deepseek-ai/cordis' } /** Return whether a parameter is the explicit TypeScript this receiver. */ diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index aa3198057b..eec3a4d942 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -134,13 +134,17 @@ describe('parseVendoredRows', () => { const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8')) expect(rows.length).toBeGreaterThan(0) - expect(rows).toContainEqual({ npmName: 'cordis', upstream: 'https://github.com/cordiverse/cordis' }) + expect(rows).toContainEqual({ + npmName: '@deepseek-ai/cordis', + upstreamName: 'cordis', + upstream: 'https://github.com/cordiverse/cordis', + }) // The upstream column carries a trailing package path for some rows; it is not part of the URL. expect(rows.every(row => /^https:\/\/\S+$/.test(row.upstream))).toBe(true) }) it('yields nothing when the table columns change, so the generator fails loud', () => { - expect(parseVendoredRows('| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([]) + expect(parseVendoredRows('| `cordis/` | `@deepseek-ai/cordis` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([]) }) it('covers every vendored directory, so no package can drop out of the notices', () => { diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index c2ab21688a..e161902974 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -387,6 +387,8 @@ export function tierExternalDeps(manifests: Map<string, Manifest>, names: Set<st /** A vendored package row parsed out of the `vendor/README.md` manifest table. */ export interface VendoredRow { npmName: string + /** The name this package carries upstream; MIT attribution names the fork's origin, not our scope. */ + upstreamName: string upstream: string } @@ -398,11 +400,12 @@ export interface VendoredRow { export function parseVendoredRows(text: string): VendoredRow[] { const rows: VendoredRow[] = [] for (const line of text.split('\n')) { - const match = /^\| \x60\S+\/\x60 \| \x60([^\x60]+)\x60 \| \S+ \| (https:\/\/\S+?)(?: \([^)]*\))? \| \x60[0-9a-f]+\x60 \|$/.exec(line) + const match = new RegExp(String.raw`^\| \x60\S+\/\x60 \| \x60([^\x60]+)\x60 \| \x60([^\x60]+)\x60 \| \S+ \| ` + + String.raw`(https:\/\/\S+?)(?: \([^)]*\))? \| \x60[0-9a-f]+\x60 \|$`).exec(line) if (match === null) continue - const [, npmName, upstream] = match - if (npmName === undefined || upstream === undefined) continue - rows.push({ npmName, upstream }) + const [, npmName, upstreamName, upstream] = match + if (npmName === undefined || upstreamName === undefined || upstream === undefined) continue + rows.push({ npmName, upstreamName, upstream }) } return rows } @@ -696,11 +699,11 @@ The complete npm transitive closure, including the Landlock launcher workspace, ## Vendored source (\`vendor/\`) -The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed; each directory preserves its upstream \`LICENSE\` file. Exact upstream commits and local modifications are recorded in [\`vendor/README.md\`](vendor/README.md). +The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm, and republished under the \`@deepseek-ai\` scope. All are MIT-licensed; each directory preserves its upstream \`LICENSE\` file. Exact upstream commits and local modifications are recorded in [\`vendor/README.md\`](vendor/README.md). -| Package | Upstream | License | -| --- | --- | --- | -${vendored.map(row => `| \`${row.npmName}\` | [${row.upstream.replace('https://', '')}](${row.upstream}) | MIT |`).join('\n')} +| Package | Upstream name | Upstream | License | +| --- | --- | --- | --- | +${vendored.map(row => `| \`${row.npmName}\` | \`${row.upstreamName}\` | [${row.upstream.replace('https://', '')}](${row.upstream}) | MIT |`).join('\n')} ## Runtime npm dependencies diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 942a9ec9e0..0fd9c3265e 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -8,7 +8,7 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs' import { basename, resolve } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { ToolSchema } from '@deepseek-ai/dsh-llm' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts index 4a33f32e1e..320f11997f 100644 --- a/scripts/publish-npm-baseline.ts +++ b/scripts/publish-npm-baseline.ts @@ -258,7 +258,9 @@ class WorkspacePackageSet { const name = expectString(manifest, 'name', manifestPath) const version = expectString(manifest, 'version', manifestPath) const isVendored = manifestPath.startsWith('vendor/') - if (!isVendored && !name.startsWith('@deepseek-ai/')) { + // Vendored packages are rescoped too (vendor/README.md), so publication + // never carries an upstream name that would squat it on the registry. + if (!name.startsWith('@deepseek-ai/')) { throw new Error(`${manifestPath} must name an @deepseek-ai package`) } if (name === '@deepseek-ai/dsh-root') { diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index db3cae7073..5613ed035e 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import { Context, FiberState, Service, ValidationError } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import z from 'schemastery' +import { Context, FiberState, Service, ValidationError } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import z from '@deepseek-ai/schemastery' import InvariantService from '@deepseek-ai/dsh-invariants' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { packageInvariantOwners } from './package-invariants.ts' @@ -13,7 +13,7 @@ import { usesManualInvariantTree, } from './test-invariants.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { testInvariantProbe: TestInvariantProbe } diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 9235f34e46..fa3c3cc7e4 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -6,8 +6,8 @@ */ import { expect } from 'vitest' -import { FiberState, Inject, RegistryService } from 'cordis' -import type { Context, Plugin } from 'cordis' +import { FiberState, Inject, RegistryService } from '@deepseek-ai/cordis' +import type { Context, Plugin } from '@deepseek-ai/cordis' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index b0aef8ddc9..a70dae4d1c 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -165,7 +165,7 @@ function validateEntry(value: unknown, file: string, path: string): void { } recordPlugin(value, file) validateMetadata(value, file, path) - if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) { + if ((value.group === true || value.name === '@deepseek-ai/cordis-plugin-group') && isUnknownArray(value.config)) { for (let index = 0; index < value.config.length; index++) { validateEntry(value.config[index], file, `${path}.config[${index}]`) } @@ -175,7 +175,7 @@ function validateEntry(value: unknown, file: string, path: string): void { validateEntry(value.insert[index], file, `${path}.insert[${index}]`) } } - if (value.name !== '@cordisjs/plugin-include') return + if (value.name !== '@deepseek-ai/cordis-plugin-include') return const config = value.config if (!isRecord(config) || !isUnknownArray(config.patches)) return for (let index = 0; index < config.patches.length; index++) { diff --git a/tsconfig.base.json b/tsconfig.base.json index 732e740ce7..b5a7e9b1e4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,15 +28,15 @@ // not declaration path aliases, keep each package/vendor source compiled // under its own tsconfig boundary. "paths": { - "cordis": ["./vendor/cordis/src"], - "cosmokit": ["./vendor/cosmokit/src"], - "schemastery": ["./vendor/schemastery/src"], - "@cordisjs/plugin-loader": ["./vendor/loader/src"], - "@cordisjs/plugin-include": ["./vendor/include/src"], - "@cordisjs/plugin-group": ["./vendor/group/src"], - "@cordisjs/plugin-timer": ["./vendor/timer/src"], - "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], - "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], + "@deepseek-ai/cordis": ["./vendor/cordis/src"], + "@deepseek-ai/cosmokit": ["./vendor/cosmokit/src"], + "@deepseek-ai/schemastery": ["./vendor/schemastery/src"], + "@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"], + "@deepseek-ai/cordis-plugin-include": ["./vendor/include/src"], + "@deepseek-ai/cordis-plugin-group": ["./vendor/group/src"], + "@deepseek-ai/cordis-plugin-timer": ["./vendor/timer/src"], + "@deepseek-ai/cordis-plugin-hmr": ["./vendor/hmr/src"], + "@deepseek-ai/cordis-plugin-logger-console": ["./vendor/logger-console/src"], "@deepseek-ai/node-addon-landlock-run": ["./native/landlock-run/packages/entry/src/index.ts"], "@deepseek-ai/dsh-invariants": ["./packages/support/invariants/src/index.ts"], "@deepseek-ai/dsh-typert-registry": ["./packages/typert/registry/src/index.ts"], diff --git a/vendor/README.md b/vendor/README.md index 87e5859212..470b517549 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -2,7 +2,7 @@ This directory contains source-vendored copies of the Cordis framework and its foundation libraries. They are copied into this monorepo instead of being depended on via npm, so that the harness fully owns its framework layer (auditable, patchable, pinned). -All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names. The `hygiene` gate `verify-vendored-links` asserts every vendored name resolves to a workspace `link:` in `pnpm-lock.yaml` with no registry copy alongside. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory. +All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`. The `hygiene` gate `verify-vendored-links` asserts every vendored name resolves to a workspace `link:` in `pnpm-lock.yaml` with no registry copy alongside. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('@deepseek-ai/cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory. This file covers the manifest, the local-modification log, and the procedure for **updating** an existing vendored package. To **add a new** one, see the cookbook guide: [docs/cookbook/adding-a-vendored-package.md](../docs/cookbook/adding-a-vendored-package.md). @@ -10,17 +10,17 @@ This file covers the manifest, the local-modification log, and the procedure for Upstream workspace: `cordis-workspace` (local checkout: `~/repos/cordis-workspace`). -| Directory | npm name | Version | Upstream repo | Commit | -|---|---|---|---|---| -| `cosmokit/` | `cosmokit` | 1.8.1 | https://github.com/deepseek-harness/cosmokit | `16f6fc058ade66e8ac5da0033d35a8d0f279f544` | -| `schemastery/` | `schemastery` | 3.18.0 | https://github.com/deepseek-harness/schemastery (`packages/core`) | `e67cee00ad725bd1534aee930a979ea3eec6f698` | -| `cordis/` | `cordis` | 4.0.0-rc.7 | https://github.com/cordiverse/cordis (`packages/core`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` | -| `loader/` | `@cordisjs/plugin-loader` | 1.0.0-rc.5 | https://github.com/cordiverse/cordis (`packages/loader`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` | -| `include/` | `@cordisjs/plugin-include` | 1.0.4 | https://github.com/deepseek-harness/cordis (`packages/include`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | -| `group/` | `@cordisjs/plugin-group` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/group`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | -| `timer/` | `@cordisjs/plugin-timer` | 1.1.2 | https://github.com/deepseek-harness/cordis (`packages/timer`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | -| `hmr/` | `@cordisjs/plugin-hmr` | 1.0.15 | https://github.com/deepseek-harness/cordis (`packages/hmr`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | -| `logger-console/` | `@cordisjs/plugin-logger-console` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/logger-console`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | +| Directory | npm name | Upstream name | Version | Upstream repo | Commit | +|---|---|---|---|---|---| +| `cosmokit/` | `@deepseek-ai/cosmokit` | `cosmokit` | 1.8.1 | https://github.com/deepseek-harness/cosmokit | `16f6fc058ade66e8ac5da0033d35a8d0f279f544` | +| `schemastery/` | `@deepseek-ai/schemastery` | `schemastery` | 3.18.0 | https://github.com/deepseek-harness/schemastery (`packages/core`) | `e67cee00ad725bd1534aee930a979ea3eec6f698` | +| `cordis/` | `@deepseek-ai/cordis` | `cordis` | 4.0.0-rc.7 | https://github.com/cordiverse/cordis (`packages/core`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` | +| `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | 1.0.0-rc.5 | https://github.com/cordiverse/cordis (`packages/loader`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` | +| `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | 1.0.4 | https://github.com/deepseek-harness/cordis (`packages/include`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | +| `group/` | `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/group`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | +| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | 1.1.2 | https://github.com/deepseek-harness/cordis (`packages/timer`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | +| `hmr/` | `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | 1.0.15 | https://github.com/deepseek-harness/cordis (`packages/hmr`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | +| `logger-console/` | `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/logger-console`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | Third-party dependencies of the vendored packages stay on npm: `@standard-schema/spec`, `js-yaml`, `chokidar`, `picomatch`, `@babel/code-frame`, `supports-color`, `node-addon-require-builtin`. @@ -44,6 +44,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 12. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. 13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. 14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. +15. **`@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). ## Sync procedure diff --git a/vendor/cordis/bin.js b/vendor/cordis/bin.js index 9aecc7ce10..e5ee5224f7 100755 --- a/vendor/cordis/bin.js +++ b/vendor/cordis/bin.js @@ -1,15 +1,15 @@ #!/usr/bin/env node -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { pathToFileURL } from 'node:url' -import Loader from '@cordisjs/plugin-loader' +import Loader from '@deepseek-ai/cordis-plugin-loader' const ctx = new Context() ctx.baseUrl = pathToFileURL(process.cwd()).href + '/' await ctx.plugin(Loader) await ctx.loader.create({ - name: '@cordisjs/plugin-include', + name: '@deepseek-ai/cordis-plugin-include', config: { path: './cordis.yml', }, diff --git a/vendor/cordis/package.json b/vendor/cordis/package.json index 80a327c2dd..ddc49b0655 100644 --- a/vendor/cordis/package.json +++ b/vendor/cordis/package.json @@ -1,5 +1,5 @@ { - "name": "cordis", + "name": "@deepseek-ai/cordis", "description": "Meta-Framework for Modern JavaScript Applications", "version": "4.0.0-rc.7", "private": true, @@ -25,19 +25,19 @@ "author": "Shigma <shigma10826@gmail.com>", "license": "MIT", "peerDependencies": { - "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.5" + "@deepseek-ai/cordis-plugin-include": "^1.0.4", + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5" }, "peerDependenciesMeta": { - "@cordisjs/plugin-include": { + "@deepseek-ai/cordis-plugin-include": { "optional": true }, - "@cordisjs/plugin-loader": { + "@deepseek-ai/cordis-plugin-loader": { "optional": true } }, "dependencies": { "@standard-schema/spec": "^1.1.0", - "cosmokit": "^1.8.1" + "@deepseek-ai/cosmokit": "^1.8.1" } } diff --git a/vendor/cordis/src/context.ts b/vendor/cordis/src/context.ts index 919ae65db3..0488423088 100644 --- a/vendor/cordis/src/context.ts +++ b/vendor/cordis/src/context.ts @@ -1,4 +1,4 @@ -import type { Dict } from 'cosmokit' +import type { Dict } from '@deepseek-ai/cosmokit' import { EventsService } from './events.ts' import { LoggerService } from './logger.ts' import { ReflectService } from './reflect.ts' diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index 2e862c97d4..e18940a830 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -1,5 +1,5 @@ -import { defineProperty } from 'cosmokit' -import type { Promisify } from 'cosmokit' +import { defineProperty } from '@deepseek-ai/cosmokit' +import type { Promisify } from '@deepseek-ai/cosmokit' import { Context } from './context.ts' import { Fiber, FiberState } from './fiber.ts' import { DisposableList, symbols } from './utils.ts' diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index 5511b39036..a8c804207d 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -1,5 +1,5 @@ -import { defineProperty, isNullable } from 'cosmokit' -import type { Awaitable, Dict } from 'cosmokit' +import { defineProperty, isNullable } from '@deepseek-ai/cosmokit' +import type { Awaitable, Dict } from '@deepseek-ai/cosmokit' import { Context } from './context.ts' import type { Plugin } from './registry.ts' import { buildOuterStack, composeError, DisposableList, getTraceable, isConstructor, isObject, symbols } from './utils.ts' diff --git a/vendor/cordis/src/logger.ts b/vendor/cordis/src/logger.ts index ad266817bb..c905bce865 100644 --- a/vendor/cordis/src/logger.ts +++ b/vendor/cordis/src/logger.ts @@ -1,4 +1,4 @@ -import { defineProperty, hyphenate } from 'cosmokit' +import { defineProperty, hyphenate } from '@deepseek-ai/cosmokit' import { Context } from './context.ts' import { Fiber } from './fiber.ts' import { createCallable, joinPrototype, symbols, type Tracker } from './utils.ts' diff --git a/vendor/cordis/src/reflect.ts b/vendor/cordis/src/reflect.ts index 63dd5b0cd2..7bbe1ae991 100644 --- a/vendor/cordis/src/reflect.ts +++ b/vendor/cordis/src/reflect.ts @@ -1,5 +1,5 @@ -import { defineProperty, isNullable } from 'cosmokit' -import type { Dict } from 'cosmokit' +import { defineProperty, isNullable } from '@deepseek-ai/cosmokit' +import type { Dict } from '@deepseek-ai/cosmokit' import { Context } from './context.ts' import { getTraceable, symbols, withProps } from './utils.ts' import { Fiber, FiberState } from './fiber.ts' diff --git a/vendor/cordis/src/registry.ts b/vendor/cordis/src/registry.ts index d013e86081..478cf036cb 100644 --- a/vendor/cordis/src/registry.ts +++ b/vendor/cordis/src/registry.ts @@ -1,5 +1,5 @@ -import { defineProperty } from 'cosmokit' -import type { Dict } from 'cosmokit' +import { defineProperty } from '@deepseek-ai/cosmokit' +import type { Dict } from '@deepseek-ai/cosmokit' import type { StandardSchemaV1 } from '@standard-schema/spec' import { Context } from './context.ts' import { Fiber } from './fiber.ts' diff --git a/vendor/cordis/src/service.ts b/vendor/cordis/src/service.ts index f58240368d..dc5742f8c2 100644 --- a/vendor/cordis/src/service.ts +++ b/vendor/cordis/src/service.ts @@ -1,4 +1,4 @@ -import { defineProperty } from 'cosmokit' +import { defineProperty } from '@deepseek-ai/cosmokit' import { Context } from './context.ts' import { createCallable, joinPrototype, symbols, type Tracker } from './utils.ts' diff --git a/vendor/cordis/src/utils.ts b/vendor/cordis/src/utils.ts index 2fd499bd0c..024d21f5e0 100644 --- a/vendor/cordis/src/utils.ts +++ b/vendor/cordis/src/utils.ts @@ -1,4 +1,4 @@ -import { defineProperty } from 'cosmokit' +import { defineProperty } from '@deepseek-ai/cosmokit' import type { Context, Service } from './index.ts' /** Ordered collection of disposable values with O(1) deletion by value. */ diff --git a/vendor/cosmokit/package.json b/vendor/cosmokit/package.json index 940fcdb539..db14a695e7 100644 --- a/vendor/cosmokit/package.json +++ b/vendor/cosmokit/package.json @@ -1,5 +1,5 @@ { - "name": "cosmokit", + "name": "@deepseek-ai/cosmokit", "description": "A collection of common utilities", "version": "1.8.1", "private": true, diff --git a/vendor/group/package.json b/vendor/group/package.json index cefb9288fa..9ddb0a132c 100644 --- a/vendor/group/package.json +++ b/vendor/group/package.json @@ -1,5 +1,5 @@ { - "name": "@cordisjs/plugin-group", + "name": "@deepseek-ai/cordis-plugin-group", "description": "Nested plugin group for cordis", "version": "1.0.0", "private": true, @@ -23,7 +23,7 @@ "author": "Shigma <shigma10826@gmail.com>", "license": "MIT", "peerDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/vendor/group/src/index.ts b/vendor/group/src/index.ts index 7654ba9691..cf27374159 100644 --- a/vendor/group/src/index.ts +++ b/vendor/group/src/index.ts @@ -1,3 +1,3 @@ -import { Group } from '@cordisjs/plugin-loader' +import { Group } from '@deepseek-ai/cordis-plugin-loader' export default Group diff --git a/vendor/hmr/package.json b/vendor/hmr/package.json index 0b498fc90c..4ebaf14959 100644 --- a/vendor/hmr/package.json +++ b/vendor/hmr/package.json @@ -1,5 +1,5 @@ { - "name": "@cordisjs/plugin-hmr", + "name": "@deepseek-ai/cordis-plugin-hmr", "description": "Hot Module Replacement Plugin for Cordis", "version": "1.0.15", "private": true, @@ -22,7 +22,7 @@ ], "author": "Shigma <shigma10826@gmail.com>", "license": "MIT", - "cordis": { + "@deepseek-ai/cordis": { "services": { "required": [ "timer" @@ -34,15 +34,15 @@ } }, "peerDependencies": { - "@cordisjs/plugin-timer": "^1.1.2", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis-plugin-timer": "^1.1.2", + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { "@babel/code-frame": "^7.29.0", "chokidar": "^4.0.3", - "cosmokit": "^1.8.1", + "@deepseek-ai/cosmokit": "^1.8.1", "picomatch": "^4.0.3", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0" }, "devDependencies": { "@types/babel__code-frame": "^7.27.0", diff --git a/vendor/hmr/src/error.ts b/vendor/hmr/src/error.ts index 80045c1765..05e9984278 100644 --- a/vendor/hmr/src/error.ts +++ b/vendor/hmr/src/error.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { BuildFailure } from 'esbuild' import { codeFrameColumns } from '@babel/code-frame' import { readFileSync } from 'node:fs' diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 169ee2e00a..290899ba1f 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -1,18 +1,18 @@ -import { Context, Service, type Plugin } from 'cordis' -import type { Dict } from 'cosmokit' -import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plugin-loader' -import type { Include } from '@cordisjs/plugin-include' +import { Context, Service, type Plugin } from '@deepseek-ai/cordis' +import type { Dict } from '@deepseek-ai/cosmokit' +import { ModuleLoader, type ModuleJob, type ResolveResult } from '@deepseek-ai/cordis-plugin-loader' +import type { Include } from '@deepseek-ai/cordis-plugin-include' import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' import { dirname, relative, resolve } from 'node:path' import { realpath, stat } from 'node:fs/promises' import { handleError } from './error.ts' -import type {} from '@cordisjs/plugin-timer' +import type {} from '@deepseek-ai/cordis-plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' import { createRequire } from 'node:module' import picomatch from 'picomatch' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { hmr: Hmr } diff --git a/vendor/include/package.json b/vendor/include/package.json index c588a33d80..3c56bd065b 100644 --- a/vendor/include/package.json +++ b/vendor/include/package.json @@ -1,5 +1,5 @@ { - "name": "@cordisjs/plugin-include", + "name": "@deepseek-ai/cordis-plugin-include", "description": "Include files in cordis configurations", "version": "1.0.4", "private": true, @@ -23,11 +23,11 @@ "author": "Shigma <shigma10826@gmail.com>", "license": "MIT", "peerDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "cosmokit": "^1.8.1", + "@deepseek-ai/cosmokit": "^1.8.1", "js-yaml": "^4.1.0" } } diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 59d34c24a3..04079cc598 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -1,5 +1,5 @@ -import { EntryTree, isJsExpr, type EntryOptions } from '@cordisjs/plugin-loader' -import { Context, Service } from 'cordis' +import { EntryTree, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { Context, Service } from '@deepseek-ai/cordis' import { extname } from 'node:path' import { access, constants, readFile, rename, writeFile } from 'node:fs/promises' import { setTimeout as delay } from 'node:timers/promises' diff --git a/vendor/loader/package.json b/vendor/loader/package.json index 1d4e757099..e535334803 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -1,5 +1,5 @@ { - "name": "@cordisjs/plugin-loader", + "name": "@deepseek-ai/cordis-plugin-loader", "description": "Plugin loader for cordis", "version": "1.0.0-rc.5", "private": true, @@ -23,7 +23,7 @@ "author": "Shigma <shigma10826@gmail.com>", "license": "MIT", "peerDependencies": { - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "^4.0.0-rc.7", "node-addon-require-builtin": "^0.1.4" }, "peerDependenciesMeta": { @@ -32,6 +32,6 @@ } }, "dependencies": { - "cosmokit": "^1.8.1" + "@deepseek-ai/cosmokit": "^1.8.1" } } diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index d479fa6c0f..215198468f 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -1,5 +1,5 @@ -import { Context, Fiber, Inject } from 'cordis' -import { deepEqual, isNullable } from 'cosmokit' +import { Context, Fiber, Inject } from '@deepseek-ai/cordis' +import { deepEqual, isNullable } from '@deepseek-ai/cosmokit' import { Loader } from '../index.ts' import { EntryGroup } from './group.ts' import { EntryTree } from './tree.ts' diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index 8b96187275..a7b0997297 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -1,4 +1,4 @@ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { Entry, type EntryOptions } from './entry.ts' import { EntryTree } from './tree.ts' diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts index 9142f3fda5..0dd2a06c92 100644 --- a/vendor/loader/src/config/isolate.ts +++ b/vendor/loader/src/config/isolate.ts @@ -1,5 +1,5 @@ -import { Context } from 'cordis' -import type { Dict } from 'cosmokit' +import { Context } from '@deepseek-ai/cordis' +import type { Dict } from '@deepseek-ai/cosmokit' import { Entry } from './entry.ts' declare module './entry.ts' { diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 8cb9fb984d..4b5ac78ef7 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -1,5 +1,5 @@ -import { composeError, Context } from 'cordis' -import { isNonNullable, type Dict } from 'cosmokit' +import { composeError, Context } from '@deepseek-ai/cordis' +import { isNonNullable, type Dict } from '@deepseek-ai/cosmokit' import { Entry, type EntryOptions } from './entry.ts' import { EntryGroup } from './group.ts' diff --git a/vendor/loader/src/config/utils.ts b/vendor/loader/src/config/utils.ts index 4e193fcc4f..cd706c0407 100644 --- a/vendor/loader/src/config/utils.ts +++ b/vendor/loader/src/config/utils.ts @@ -1,4 +1,4 @@ -import { valueMap } from 'cosmokit' +import { valueMap } from '@deepseek-ai/cosmokit' // eslint-disable-next-line no-new-func /** Evaluate a JavaScript expression against a loader context scope. */ diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index 798354c7b0..fa1f852cff 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -1,5 +1,5 @@ -import { Context, Inject, Service } from 'cordis' -import { defineProperty, isNullable, type Dict } from 'cosmokit' +import { Context, Inject, Service } from '@deepseek-ai/cordis' +import { defineProperty, isNullable, type Dict } from '@deepseek-ai/cosmokit' import { ModuleLoader } from './internal.ts' import { Entry, type EntryOptions } from './config/entry.ts' import isolate from './config/isolate.ts' @@ -18,7 +18,7 @@ export * from './config/utils.ts' /** Re-export Node internal module loader compatibility types. */ export * from './internal.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { 'exit'(signal: NodeJS.Signals): Promise<void> 'loader/config-update'(): void diff --git a/vendor/loader/src/internal.ts b/vendor/loader/src/internal.ts index 38d6f589f5..ccf08debc6 100644 --- a/vendor/loader/src/internal.ts +++ b/vendor/loader/src/internal.ts @@ -1,5 +1,5 @@ import { createRequire, type LoadHookContext } from 'node:module' -import type { Dict } from 'cosmokit' +import type { Dict } from '@deepseek-ai/cosmokit' /** Node internal module format names handled by loader hooks. */ export type ModuleFormat = 'builtin' | 'commonjs' | 'json' | 'module' | 'wasm' diff --git a/vendor/logger-console/package.json b/vendor/logger-console/package.json index 7af021c45a..b1cc3734bb 100644 --- a/vendor/logger-console/package.json +++ b/vendor/logger-console/package.json @@ -1,5 +1,5 @@ { - "name": "@cordisjs/plugin-logger-console", + "name": "@deepseek-ai/cordis-plugin-logger-console", "description": "Console logger exporter for cordis", "version": "1.0.0", "private": true, @@ -25,11 +25,11 @@ "author": "Shigma <shigma10826@gmail.com>", "license": "MIT", "peerDependencies": { - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "cosmokit": "^1.8.1", - "schemastery": "^3.18.0", + "@deepseek-ai/cosmokit": "^1.8.1", + "@deepseek-ai/schemastery": "^3.18.0", "supports-color": "^9.4.0" } } diff --git a/vendor/logger-console/src/browser.ts b/vendor/logger-console/src/browser.ts index b45a15e228..e77ec3c3fb 100644 --- a/vendor/logger-console/src/browser.ts +++ b/vendor/logger-console/src/browser.ts @@ -1,4 +1,4 @@ -import { Message } from 'cordis' +import { Message } from '@deepseek-ai/cordis' import { ConsoleExporter as Base } from './shared.ts' /** Re-export shared console exporter config and base implementation. */ diff --git a/vendor/logger-console/src/index.ts b/vendor/logger-console/src/index.ts index d46ac6413f..3ed272a9ec 100644 --- a/vendor/logger-console/src/index.ts +++ b/vendor/logger-console/src/index.ts @@ -1,4 +1,4 @@ -import { Formatter } from 'cordis' +import { Formatter } from '@deepseek-ai/cordis' import { inspect } from 'node:util' import supportsColor from 'supports-color' import { ConsoleExporter as Base } from './shared.ts' diff --git a/vendor/logger-console/src/shared.ts b/vendor/logger-console/src/shared.ts index 61d91abcb3..942bc54746 100644 --- a/vendor/logger-console/src/shared.ts +++ b/vendor/logger-console/src/shared.ts @@ -1,6 +1,6 @@ -import { Context, Exporter, Formatter, Logger, Message } from 'cordis' -import { Time } from 'cosmokit' -import z from 'schemastery' +import { Context, Exporter, Formatter, Logger, Message } from '@deepseek-ai/cordis' +import { Time } from '@deepseek-ai/cosmokit' +import z from '@deepseek-ai/schemastery' /** Terminal color support level compatible with supports-color. */ export type ColorSupportLevel = 0 | 1 | 2 | 3 diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index f23fac56db..f2a0c61e5a 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -1,5 +1,5 @@ { - "name": "schemastery", + "name": "@deepseek-ai/schemastery", "description": "Type driven schema validator", "version": "3.18.0", "private": true, @@ -27,6 +27,6 @@ "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", - "cosmokit": "^1.8.1" + "@deepseek-ai/cosmokit": "^1.8.1" } } diff --git a/vendor/schemastery/src/index.ts b/vendor/schemastery/src/index.ts index 5948797ae9..56a499e1cc 100644 --- a/vendor/schemastery/src/index.ts +++ b/vendor/schemastery/src/index.ts @@ -1,4 +1,4 @@ -import { Binary, clone, deepEqual, filterKeys, isNullable, isPlainObject, pick, valueMap, type Dict } from 'cosmokit' +import { Binary, clone, deepEqual, filterKeys, isNullable, isPlainObject, pick, valueMap, type Dict } from '@deepseek-ai/cosmokit' import type { StandardSchemaV1 } from '@standard-schema/spec' const kSchema = Symbol.for('schemastery') diff --git a/vendor/timer/package.json b/vendor/timer/package.json index 4ae59cd0bd..9bf741fc51 100644 --- a/vendor/timer/package.json +++ b/vendor/timer/package.json @@ -1,5 +1,5 @@ { - "name": "@cordisjs/plugin-timer", + "name": "@deepseek-ai/cordis-plugin-timer", "description": "Timer service for cordis", "version": "1.1.2", "private": true, @@ -23,9 +23,9 @@ "author": "Shigma <shigma10826@gmail.com>", "license": "MIT", "peerDependencies": { - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "dependencies": { - "cosmokit": "^1.8.1" + "@deepseek-ai/cosmokit": "^1.8.1" } } diff --git a/vendor/timer/src/index.ts b/vendor/timer/src/index.ts index 1a33850aa3..009d14a5d4 100644 --- a/vendor/timer/src/index.ts +++ b/vendor/timer/src/index.ts @@ -1,6 +1,6 @@ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context extends Pick<TimerService, 'interval' | 'timeout' | 'throttle' | 'debounce' | 'setTimeout' | 'setInterval'> { timer: TimerService } From e41896d91dd1bbe2bb3d2a9b2b20064642da7acc Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 22:14:32 +0800 Subject: [PATCH 141/229] test(boot): keep absolute plugin case host-owned --- packages/boot/app-boot/tests/app-boot.spec.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index dc4e92dc4a..a254102171 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -590,26 +590,31 @@ describe('boot', () => { ].join('\n')) writeFileSync(join(dir, 'relative.mjs'), 'export function apply(ctx) { ctx.provide("relativePluginLoaded", true) }\n') writeFileSync(absolutePlugin, 'export function apply(ctx) { ctx.provide("absolutePluginLoaded", true) }\n') - writeFileSync(join(dir, 'cordis.yml'), [ + const entries = [ '- id: prompt', " name: '@deepseek-ai/dsh-system-prompt'", '- id: relative', " name: './relative.mjs'", + ] + const configOwnedPath = join(dir, 'config-owned.cordis.yml') + writeFileSync(configOwnedPath, [...entries, ''].join('\n')) + const hostOwnedPath = join(dir, 'host-owned.cordis.yml') + writeFileSync(hostOwnedPath, [ + ...entries, '- id: absolute', ` name: ${JSON.stringify(absolutePlugin)}`, '', ].join('\n')) - const configOwned = await boot(NAME, join(dir, 'cordis.yml')) + const configOwned = await boot(NAME, configOwnedPath) try { expect(configOwned.get('shadowPluginLoaded')).toBe(true) expect(configOwned.get('systemPrompt')).toBeUndefined() expect(configOwned.get('relativePluginLoaded')).toBe(true) - expect(configOwned.get('absolutePluginLoaded')).toBe(true) } finally { await configOwned.fiber.dispose() } const harnessBaseUrl = pathToFileURL(join(harness, 'entry.mjs')).href - const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, undefined, harnessBaseUrl) + const ctx = await boot(NAME, hostOwnedPath, undefined, undefined, harnessBaseUrl) try { expect(ctx.get('harnessPluginLoaded')).toBe(true) expect(ctx.get('shadowPluginLoaded')).toBeUndefined() From 4f23fa84ccc8c014050bd0fde221ecf990282ce3 Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Mon, 10 Aug 2026 22:19:14 +0800 Subject: [PATCH 142/229] 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<void> | undefined const disposeProcess = (): Promise<void> => (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<SubagentRun>` 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<SubagentRun>` 是所有权转移边界;委派工具也会在其由 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 b668e6f120bdc0b1817289b6edb115254983c6bc Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 22:25:02 +0800 Subject: [PATCH 143/229] fix(packaging): dereference restored runtime deps --- apps/cli/tests/web-agent-presets.e2e.ts | 1 - scripts/build-exe-for-python-sdk.ts | 1 + 2 files 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 13ce152807..d36531c640 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -366,7 +366,6 @@ describe('the shipped Web composition', () => { expect(await readFile(path, 'utf8')).toBe(before) }) - }) describe('a switch survives the session', () => { diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 34ecdda1c0..8030168a05 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -296,6 +296,7 @@ class SingleExeBuild { const nestedNodeModules = join(source, 'node_modules') await cp(source, destination, { recursive: true, + dereference: true, filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep), }) restored.push(dependency) From 8fd2f0a813864045cdaa061185e31d7febce7ada Mon Sep 17 00:00:00 2001 From: pku-xht <xht@deepseek.com> Date: Mon, 10 Aug 2026 22:27:26 +0800 Subject: [PATCH 144/229] fix(examples): follow vendor package rescope --- examples/acp-agent/product-subagent-both.cordis.snapshot.yml | 2 +- examples/acp-agent/product-subagent-both.cordis.yml | 2 +- examples/acp-agent/product-subagent-codex.cordis.snapshot.yml | 2 +- examples/acp-agent/product-subagent-codex.cordis.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml index 2863c80641..73d22eae27 100644 --- a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml +++ b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless twin of product-subagent-both.cordis.yml: preserve both product # tools while replacing only the external model adapter. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/product-subagent-both.cordis.yml b/examples/acp-agent/product-subagent-both.cordis.yml index 7d6269352a..ce9d054058 100644 --- a/examples/acp-agent/product-subagent-both.cordis.yml +++ b/examples/acp-agent/product-subagent-both.cordis.yml @@ -2,7 +2,7 @@ # rows an Agent Preset may contribute. Loading the composition starts neither # product; the scenario pins both model-visible schemas. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml index 74823e5da5..8b8cd8604c 100644 --- a/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml +++ b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless twin of product-subagent-codex.cordis.yml: keep the same product # provider/tool composition and replace only the external model adapter. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/product-subagent-codex.cordis.yml b/examples/acp-agent/product-subagent-codex.cordis.yml index 169acee9a7..55f9985b7d 100644 --- a/examples/acp-agent/product-subagent-codex.cordis.yml +++ b/examples/acp-agent/product-subagent-codex.cordis.yml @@ -2,7 +2,7 @@ # the real ACP composition. The model is told not to call it; the scenario pins # the assembled request schema without starting Codex. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: From 75f443478771d038cc9e887736a5064eb09b0f1e Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 22:27:34 +0800 Subject: [PATCH 145/229] fix(packaging): close rescoped runtime deploy --- pnpm-lock.yaml | 3 +++ python/sdk-runtime/package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ee482847e..7fef44c18c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7554,6 +7554,9 @@ importers: '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../vendor/timer + '@deepseek-ai/cosmokit': + specifier: workspace:^ + version: link:../../vendor/cosmokit '@deepseek-ai/dsh-acp': specifier: workspace:^ version: link:../../packages/acp/acp diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 17ba80ba3c..4c1c8d7ea2 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -9,6 +9,7 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/cordis-plugin-timer": "workspace:^", + "@deepseek-ai/cosmokit": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", From a7e88053404445c2c355ff6f9aae5d3b7e640594 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 22:47:18 +0800 Subject: [PATCH 146/229] fix(packaging): materialize runtime dependencies --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- pnpm-lock.yaml | 250 +++++++++--------- pnpm-workspace.yaml | 4 + scripts/build-exe-for-python-sdk.ts | 46 +++- 6 files changed, 181 insertions(+), 127 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 18e4bcf009..a48de33ce2 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 29aa14db2b3b64cefb7960ee8f316682ba1ac736 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: f10d72b394246e9e57033a5382ec977bdc6f3ab8 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: c2b6d9ff1825915e39738bf8f782c302ecfc1d0d +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d7758a77083e07b1d2cac99ae2be3f15e6edd2dc diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 29aa14db2b..c2b6d9ff18 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -40,7 +40,7 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local symlink tree and rejecting any remaining manifest gap → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink package payload (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index f10d72b394..d7758a7708 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -40,7 +40,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建管线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内符号链接树,并拒绝剩余的 manifest 缺口 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的包载荷(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR(Pull Request)添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fef44c18c..e7145b8b2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@deepseek-ai/cosmokit': link:vendor/cosmokit + '@deepseek-ai/schemastery': link:vendor/schemastery + patchedDependencies: node-pty@1.1.0: 7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6 @@ -735,7 +739,7 @@ importers: specifier: 0.25.1 version: 0.25.1(zod@4.4.3) '@deepseek-ai/schemastery': - specifier: ^3.17.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -834,7 +838,7 @@ importers: packages/attachment/attachment-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery sharp: specifier: ^0.35.3 @@ -871,7 +875,7 @@ importers: packages/bash/bash-env: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -902,7 +906,7 @@ importers: packages/bash/bash-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -957,7 +961,7 @@ importers: packages/bash/pwsh-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -1009,7 +1013,7 @@ importers: packages/bash/tool-bash: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -1076,7 +1080,7 @@ importers: packages/bash/tool-pwsh: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -1418,7 +1422,7 @@ importers: specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -1569,7 +1573,7 @@ importers: specifier: workspace:^ version: link:../../workspace/workspace '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -1606,7 +1610,7 @@ importers: specifier: workspace:^ version: link:../../core/tools '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery ws: specifier: ^8.21.0 @@ -1628,7 +1632,7 @@ importers: packages/client/hmr: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -1656,7 +1660,7 @@ importers: specifier: workspace:^ version: link:../../settings/settings '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -1766,7 +1770,7 @@ importers: specifier: workspace:^ version: link:../../typert/registry '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery '@types/react': specifier: ~18.3.1 @@ -1775,7 +1779,7 @@ importers: packages/client/schema-form: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -1919,7 +1923,7 @@ importers: specifier: workspace:^ version: link:../../settings/settings '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery clsx: specifier: ^2.0.0 @@ -2446,7 +2450,7 @@ importers: specifier: workspace:^ version: link:../../settings/settings '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -2666,7 +2670,7 @@ importers: specifier: workspace:^ version: link:../../settings/settings '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery clsx: specifier: ^2.0.0 @@ -2915,7 +2919,7 @@ importers: packages/code-runtime/code-runtime-worker: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -2988,7 +2992,7 @@ importers: packages/compact/compact-basic: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3040,7 +3044,7 @@ importers: packages/compact/compact-tool-result-prune: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3071,7 +3075,7 @@ importers: packages/context/session-reference: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3102,7 +3106,7 @@ importers: packages/context/time-context: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3139,7 +3143,7 @@ importers: packages/context/tmux-context: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3167,7 +3171,7 @@ importers: packages/context/workspace-context: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3243,7 +3247,7 @@ importers: packages/core/agent-default-model: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3265,7 +3269,7 @@ importers: packages/core/agent-loop: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3302,7 +3306,7 @@ importers: packages/core/agent-tool-mode: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3366,7 +3370,7 @@ importers: packages/core/system-prompt: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3385,7 +3389,7 @@ importers: packages/core/tools: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3431,7 +3435,7 @@ importers: packages/credentials/credentials-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery chokidar: specifier: ^4.0.3 @@ -3462,7 +3466,7 @@ importers: packages/e2b/e2b: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery e2b: specifier: 2.29.1 @@ -3499,7 +3503,7 @@ importers: packages/e2b/subprocess-e2b: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3519,6 +3523,10 @@ importers: version: link:../../util/timeout packages/examples/acp-demo: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 @@ -3565,14 +3573,11 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../context/workspace-context - '@deepseek-ai/schemastery': - specifier: ^3.17.0 - version: link:../../../vendor/schemastery packages/examples/agent-spine-demo: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3745,7 +3750,7 @@ importers: packages/fs/fs-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery koffi: specifier: ^3.1.0 @@ -3803,7 +3808,7 @@ importers: packages/fs/tool-fs: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery diff: specifier: ^9.0.0 @@ -3861,7 +3866,7 @@ importers: packages/fs/tool-fs-search: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery '@vscode/ripgrep': specifier: ^1.18.0 @@ -3907,7 +3912,7 @@ importers: packages/fs/tool-str-replace-editor: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -3980,7 +3985,7 @@ importers: packages/goal/goal: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.17.2 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -4053,7 +4058,7 @@ importers: packages/goal/tool-goal: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4087,7 +4092,7 @@ importers: packages/guard/repeat-tool-guard: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4151,7 +4156,7 @@ importers: packages/hooks/hooks-claude: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4203,7 +4208,7 @@ importers: packages/hooks/hooks-codex: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4324,7 +4329,7 @@ importers: specifier: workspace:^ version: link:../../workspace/workspace '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -4394,7 +4399,7 @@ importers: specifier: workspace:^ version: link:../directory-picker '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery clsx: specifier: ^2.0.0 @@ -4471,7 +4476,7 @@ importers: packages/host/frontend-static: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4490,7 +4495,7 @@ importers: packages/host/webserver: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4524,7 +4529,7 @@ importers: packages/interaction/permission: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -4588,7 +4593,7 @@ importers: packages/interaction/user-approval: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4634,7 +4639,7 @@ importers: packages/llm/llm: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4656,7 +4661,7 @@ importers: packages/llm/llm-deepseek: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery eventsource-parser: specifier: ^3.1.0 @@ -4687,7 +4692,7 @@ importers: packages/llm/llm-pi-ai: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery '@earendil-works/pi-ai': specifier: ^0.82.1 @@ -4724,7 +4729,7 @@ importers: packages/llm/llm-retry: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4782,7 +4787,7 @@ importers: packages/llm/token-meter: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -4825,7 +4830,7 @@ importers: packages/lsp/lsp-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4868,7 +4873,7 @@ importers: packages/lsp/tool-lsp: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -4914,7 +4919,7 @@ importers: packages/mcp/mcp-client: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery '@modelcontextprotocol/sdk': specifier: ^1.12.0 @@ -4991,7 +4996,7 @@ importers: packages/preset/agent-presets: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery js-yaml: specifier: ^4.1.0 @@ -5046,7 +5051,7 @@ importers: packages/preset/persona: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5083,7 +5088,7 @@ importers: packages/pty/pty-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5117,7 +5122,7 @@ importers: packages/pty/tool-bash-persistent: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5169,7 +5174,7 @@ importers: packages/pty/tool-pty: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5251,7 +5256,7 @@ importers: specifier: workspace:* version: link:../../../native/landlock-run/packages/entry '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5273,7 +5278,7 @@ importers: packages/sandbox/sandbox-policy: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5452,7 +5457,7 @@ importers: packages/scaffold/server: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.17.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5514,7 +5519,7 @@ importers: packages/self-modification/tool-cordis: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5581,7 +5586,7 @@ importers: packages/session-query/session-query-sqlite: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5609,7 +5614,7 @@ importers: packages/session-query/tool-session-query: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5718,7 +5723,7 @@ importers: packages/session/session-persistence-jsonl: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery koffi: specifier: ^3.1.0 @@ -5740,7 +5745,7 @@ importers: packages/session/session-persistence-sqlite: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5775,7 +5780,7 @@ importers: packages/session/session-projection-cache: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -5821,7 +5826,7 @@ importers: packages/session/session-telemetry-otel: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery '@opentelemetry/api': specifier: ^1.9.1 @@ -5870,7 +5875,7 @@ importers: packages/session/session-title: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -5904,7 +5909,7 @@ importers: packages/session/session-title-all-messages-llm: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5929,7 +5934,7 @@ importers: packages/session/session-title-first-message-llm: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -5963,7 +5968,7 @@ importers: packages/session/session-title-llm: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6001,6 +6006,10 @@ importers: version: link:../../util/paths packages/settings/settings: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 @@ -6011,14 +6020,11 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@deepseek-ai/schemastery': - specifier: ^3.18.0 - version: link:../../../vendor/schemastery packages/settings/settings-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery chokidar: specifier: ^4.0.3 @@ -6046,7 +6052,7 @@ importers: packages/skill/skill: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6077,7 +6083,7 @@ importers: packages/skill/skill-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery chokidar: specifier: ^5.0.0 @@ -6105,7 +6111,7 @@ importers: packages/skill/tool-skill: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6157,7 +6163,7 @@ importers: packages/spill/spill-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6182,7 +6188,7 @@ importers: packages/spill/spill-policy: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6225,7 +6231,7 @@ importers: packages/storage/storage-domain: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -6244,7 +6250,7 @@ importers: packages/storage/storage-json: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6260,7 +6266,7 @@ importers: packages/storage/storage-sqlite: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6331,7 +6337,7 @@ importers: specifier: 0.25.1 version: 0.25.1(zod@4.4.3) '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6377,7 +6383,7 @@ importers: specifier: 0.93.0 version: 0.93.0(zod@4.4.3) '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6414,7 +6420,7 @@ importers: packages/subagent/subagent-codex: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6460,7 +6466,7 @@ importers: packages/subagent/subagent-dsh-sdk: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6500,7 +6506,7 @@ importers: packages/subagent/subagent-fork: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6594,7 +6600,7 @@ importers: packages/subagent/subagent-spawn: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6646,7 +6652,7 @@ importers: packages/subagent/tool-subagent: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6740,7 +6746,7 @@ importers: packages/subagent/tool-subagent-report: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6863,7 +6869,7 @@ importers: packages/support/invariants: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -6970,7 +6976,7 @@ importers: packages/tasks/tool-tasks: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7007,7 +7013,7 @@ importers: packages/todo/tool-todo: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 @@ -7084,7 +7090,7 @@ importers: packages/typert/loader: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7194,7 +7200,7 @@ importers: packages/web/tool-web: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery '@joplin/turndown-plugin-gfm': specifier: ^1.0.67 @@ -7249,7 +7255,7 @@ importers: packages/web/web: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7265,7 +7271,7 @@ importers: packages/web/web-fetch-local: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7284,7 +7290,7 @@ importers: packages/web/web-search-deepseek: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7315,7 +7321,7 @@ importers: packages/web/web-search-exa: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7334,7 +7340,7 @@ importers: packages/web/web-search-perplexity: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7353,7 +7359,7 @@ importers: packages/workflow/tool-ralph: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7405,7 +7411,7 @@ importers: packages/workflow/tool-workflow: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7463,7 +7469,7 @@ importers: packages/workflow/workflow-workerthread: dependencies: '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': @@ -7555,7 +7561,7 @@ importers: specifier: workspace:^ version: link:../../vendor/timer '@deepseek-ai/cosmokit': - specifier: workspace:^ + specifier: link:../../vendor/cosmokit version: link:../../vendor/cosmokit '@deepseek-ai/dsh-acp': specifier: workspace:^ @@ -7855,7 +7861,7 @@ importers: specifier: workspace:^ version: link:../../packages/context/workspace-context '@deepseek-ai/schemastery': - specifier: workspace:^ + specifier: link:../../vendor/schemastery version: link:../../vendor/schemastery vendor/cordis: @@ -7867,7 +7873,7 @@ importers: specifier: ^1.0.0-rc.5 version: link:../loader '@deepseek-ai/cosmokit': - specifier: ^1.8.1 + specifier: link:../cosmokit version: link:../cosmokit '@standard-schema/spec': specifier: ^1.1.0 @@ -7896,10 +7902,10 @@ importers: specifier: ^1.1.2 version: link:../timer '@deepseek-ai/cosmokit': - specifier: ^1.8.1 + specifier: link:../cosmokit version: link:../cosmokit '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../schemastery version: link:../schemastery chokidar: specifier: ^4.0.3 @@ -7927,7 +7933,7 @@ importers: specifier: ^1.0.0-rc.5 version: link:../loader '@deepseek-ai/cosmokit': - specifier: ^1.8.1 + specifier: link:../cosmokit version: link:../cosmokit js-yaml: specifier: ^4.1.0 @@ -7939,7 +7945,7 @@ importers: specifier: ^4.0.0-rc.7 version: link:../cordis '@deepseek-ai/cosmokit': - specifier: ^1.8.1 + specifier: link:../cosmokit version: link:../cosmokit node-addon-require-builtin: specifier: ^0.1.4 @@ -7951,10 +7957,10 @@ importers: specifier: ^4.0.0-rc.7 version: link:../cordis '@deepseek-ai/cosmokit': - specifier: ^1.8.1 + specifier: link:../cosmokit version: link:../cosmokit '@deepseek-ai/schemastery': - specifier: ^3.18.0 + specifier: link:../schemastery version: link:../schemastery supports-color: specifier: ^9.4.0 @@ -7963,7 +7969,7 @@ importers: vendor/schemastery: dependencies: '@deepseek-ai/cosmokit': - specifier: ^1.8.1 + specifier: link:../cosmokit version: link:../cosmokit '@standard-schema/spec': specifier: ^1.1.0 @@ -7975,7 +7981,7 @@ importers: specifier: ^4.0.0-rc.7 version: link:../cordis '@deepseek-ai/cosmokit': - specifier: ^1.8.1 + specifier: link:../cosmokit version: link:../cosmokit website: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5a3b1e8f35..e8d8ee5bec 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,6 +24,10 @@ packages: # builds must resolve those matching names to this workspace's pinned sources. linkWorkspacePackages: true +overrides: + '@deepseek-ai/cosmokit': 'link:vendor/cosmokit' + '@deepseek-ai/schemastery': 'link:vendor/schemastery' + peerDependencyRules: allowedVersions: typescript: '>=5 <7' diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 8030168a05..a16aa93c97 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -8,7 +8,7 @@ import { spawn } from 'node:child_process' import { existsSync, statSync } from 'node:fs' -import { chmod, copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -259,6 +259,7 @@ class SingleExeBuild { this.staging, ]) await this.restoreLegacyHoists() + await this.materializeStagedLinks() if (this.cli.dryRun) { for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`) } else { @@ -311,6 +312,49 @@ class SingleExeBuild { } } + /** Replace deploy-time package links with files and reject any remaining link. */ + private async materializeStagedLinks(): Promise<void> { + if (this.cli.dryRun) { + console.log('build-exe-for-python-sdk: [dry-run] materialize staged package links') + return + } + const nodeModules = join(this.staging, 'node_modules') + let remaining = await this.findSymlink(nodeModules) + while (remaining !== undefined) { + const segments = remaining.slice(nodeModules.length + 1).split(sep) + const binIndex = segments.lastIndexOf('.bin') + if (binIndex >= 0) { + await rm(join(nodeModules, ...segments.slice(0, binIndex + 1)), { recursive: true, force: true }) + remaining = await this.findSymlink(nodeModules) + continue + } + const destination = remaining + const source = await realpath(destination) + const nestedNodeModules = join(source, 'node_modules') + await rm(destination, { recursive: true, force: true }) + await cp(source, destination, { + recursive: true, + dereference: true, + filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep), + }) + remaining = await this.findSymlink(nodeModules) + } + } + + /** Return the first symbolic link below a directory, if one exists. */ + private async findSymlink(directory: string): Promise<string | undefined> { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + const metadata = await lstat(path) + if (metadata.isSymbolicLink()) return path + if (metadata.isDirectory()) { + const nested = await this.findSymlink(path) + if (nested !== undefined) return nested + } + } + return undefined + } + /** Add the executable entry and pkg assets to the staged manifest. */ async injectPkgConfig(): Promise<void> { const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } } From 114b41eca41786b370e79f96b1177c699673d6b6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 22:47:28 +0800 Subject: [PATCH 147/229] refactor(presets): use one editor family --- ...31-even-out-shipped-tool-rosters.i18n.yaml | 4 +-- ...026-07-31-even-out-shipped-tool-rosters.md | 4 +-- ...-07-31-even-out-shipped-tool-rosters.zh.md | 4 +-- ...10-default-presets-single-editor.i18n.yaml | 6 +++++ ...026-08-10-default-presets-single-editor.md | 25 +++++++++++++++++++ ...-08-10-default-presets-single-editor.zh.md | 25 +++++++++++++++++++ .../agent-presets/code/agent.cordis.yml | 7 +----- .../agent-presets/cordis/agent.cordis.yml | 7 +----- .../agent-presets/standard/agent.cordis.yml | 7 +----- apps/cli/tests/web-agent-presets.e2e.ts | 7 +++--- 10 files changed, 69 insertions(+), 27 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md create mode 100644 .agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index 151d74e3dc..41756caeb4 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: 0195620055da5e570d2f54792d950a88bab8d652 -2026-07-31-even-out-shipped-tool-rosters.zh.md: ab6982e33c4a0a25cbc2fde386456840ce99d9c5 +2026-07-31-even-out-shipped-tool-rosters.md: 7647506e5d9c39d64b686ab18923f9681a48cd87 +2026-07-31-even-out-shipped-tool-rosters.zh.md: f04a59c9f00455b00f70975ad9b6bd4defd3d847 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index 0195620055..7647506e5d 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -12,11 +12,11 @@ The result was a user-visible difference nobody had decided: the same model, ask ## Decision -The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster: twenty-two tools on every host — the twenty shared rows plus `glob` and `grep`, which are fixed members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../architecture/2026-08-01-packaged-ripgrep-search.md). `tool-session-query` joined and then left again — the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md) keeps the model-facing consumer opt-in — while the rest of this roster stands. +The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster, including fixed `glob` and `grep` members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../architecture/2026-08-01-packaged-ripgrep-search.md). Two later decisions narrow that roster: the [session-search decision](2026-08-02-session-search-not-shipped-default.md) keeps `tool-session-query` opt-in, and the [single-editor decision](../simplification/2026-08-10-default-presets-single-editor.md) keeps `tool-str-replace-editor` out of the general-purpose presets while retaining it in `minimal`. Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search. -**This roster decision added only at the time.** No tool row was removed from either surface when it landed, and a catalog comparison found additions and nothing else. One of those additions, `tool-session-query`, was subsequently removed by the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md). The shared executors, sandbox composition, and access default are owned independently by the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md). +**This roster decision added only at the time.** No tool row was removed from either surface when it landed, and a catalog comparison found additions and nothing else. The later session-search and single-editor decisions own their respective default-roster exceptions. The shared executors, sandbox composition, and access default are owned independently by the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md). ### What stays unmounted, and why diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index ab6982e33c..f04a59c9f0 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -12,11 +12,11 @@ Status: implemented ## 决策 -那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单:每台宿主上都有二十二个工具——二十个共享行加上 `glob` 和 `grep`,它们成为固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../architecture/2026-08-01-packaged-ripgrep-search.md)。`tool-session-query` 加入后又退出了——[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)让面向模型的消费方保持需显式启用——而这份清单的其余部分保持不变。 +那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单,其中 `glob` 和 `grep` 是固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../architecture/2026-08-01-packaged-ripgrep-search.md)。之后有两项决策收窄这份清单:[session-search 决策](2026-08-02-session-search-not-shipped-default.md)让 `tool-session-query` 保持需显式启用,[单一编辑器决策](../simplification/2026-08-10-default-presets-single-editor.md)让通用 preset 不提供 `tool-str-replace-editor`,但在 `minimal` 中保留它。 有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。 -**本次工具清单决策当时只做加法。** 落地时两个 surface 均未移除任何工具行,目录对比只发现了新增,别无其他。这些新增中的一项 `tool-session-query` 随后被[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)移除。共享执行器、沙箱组合与访问默认值独立归属[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)。 +**本次工具清单决策当时只做加法。** 落地时两个 surface 均未移除任何工具行,目录对比只发现了新增,别无其他。后续的 session-search 与单一编辑器决策分别负责对应的默认清单例外。共享执行器、沙箱组合与访问默认值独立归属[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)。 ### 什么保持不挂,以及为什么 diff --git a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml new file mode 100644 index 0000000000..e67d7df745 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md +2026-08-10-default-presets-single-editor.md: 82f254079080aeb88d76f4e7cc2c7ab195646ec4 +2026-08-10-default-presets-single-editor.zh.md: 22bbc19feadfb71fe3a9480bc60b8777e1fb50c2 diff --git a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md new file mode 100644 index 0000000000..82f2540790 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md @@ -0,0 +1,25 @@ +# Agent Note: One editor family in general-purpose presets + +Status: implemented + +English | [中文](2026-08-10-default-presets-single-editor.zh.md) + +## Problem + +The `standard`, `code`, and `cordis` presets exposed both the `read`/`write`/`edit` filesystem tools and `str_replace_editor`. The two interfaces overlap for ordinary file inspection and editing, so every request carried an additional tool schema without adding a distinct default capability. The `minimal` preset has a different composition contract: its exact two-tool roster intentionally includes `str_replace_editor` beside persistent `bash`. + +## Decision + +The `standard`, `code`, and `cordis` preset configurations mount `dsh-tool-fs` and `dsh-tool-fs-search`, but do not mount `dsh-tool-str-replace-editor`. Code Mode therefore omits `str_replace_editor` from both its registry and generated SDK. The `minimal` preset continues to mount `dsh-tool-str-replace-editor`, and deployments or user-authored presets may still mount the plugin explicitly. + +This decision narrows the preset roster rather than removing the tool package or its Python runtime support. The earlier [shared-roster decision](../feature/2026-07-31-even-out-shipped-tool-rosters.md) continues to own why surface-neutral tools live in preset composition; this note owns the editor exception. + +## Alternatives considered + +**Keep both editing interfaces in the general-purpose presets.** Rejected because the overlapping model-visible schemas increase tool choice without supplying a separate default operation. + +**Remove `str_replace_editor` from every shipped composition.** Rejected because the `minimal` preset intentionally exposes that schema as one of its two tools, and explicit deployments remain valid consumers of the standalone plugin. + +## Consequences + +General-purpose agents use `read`, `write`, and `edit` for filesystem mutations, while the minimal agent retains `str_replace_editor`. Preset composition tests pin its absence from the standard roster, the Cordis roster, and the Code Mode SDK, while the minimal assertions continue to pin its presence. diff --git a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md new file mode 100644 index 0000000000..22bbc19fea --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 通用 preset 只提供一套编辑工具 + +Status: implemented + +[English](2026-08-10-default-presets-single-editor.md) | 中文 + +## 问题 + +`standard`、`code` 和 `cordis` preset 同时提供 `read`/`write`/`edit` 文件系统工具与 `str_replace_editor`。两套接口在常规文件查看和编辑上重叠,导致每次请求都携带额外的工具 schema,却没有增加独立的默认能力。`minimal` preset 具有不同的组合约定:它固定的双工具清单有意在持久 `bash` 之外提供 `str_replace_editor`。 + +## 决策 + +`standard`、`code` 和 `cordis` preset 配置挂载 `dsh-tool-fs` 与 `dsh-tool-fs-search`,但不挂载 `dsh-tool-str-replace-editor`。因此 Code Mode 的注册表和生成的 SDK 均不包含 `str_replace_editor`。`minimal` preset 继续挂载 `dsh-tool-str-replace-editor`,部署配置或用户自定义 preset 仍可显式挂载该插件。 + +此决策收窄 preset 工具清单,不移除工具包及其 Python 运行时支持。较早的[共享清单决策](../feature/2026-07-31-even-out-shipped-tool-rosters.md)继续说明与 surface 无关的工具为何归 preset 组合所有;本记录说明编辑器例外。 + +## 曾考虑的替代方案 + +**在通用 preset 中保留两套编辑接口。** 不予采用,因为重叠的模型可见 schema 增加了工具选择,却没有提供不同的默认操作。 + +**从所有交付组合中移除 `str_replace_editor`。** 不予采用,因为 `minimal` preset 有意将该 schema 作为两个工具之一,显式部署仍是该独立插件的有效消费方。 + +## 后果 + +通用 agent 使用 `read`、`write` 和 `edit` 完成文件系统修改,minimal agent 保留 `str_replace_editor`。preset 组合测试固定其不会出现在 standard 清单、Cordis 清单及 Code Mode SDK 中,同时 minimal 断言继续固定其存在。 diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 5e273bb955..9c8a3ec9c9 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -53,7 +53,7 @@ # ── filesystem ────────────────────────────────────────────────────────────── -# All three register into the host `tools` registry and provide nothing, so +# Both register into the host `tools` registry and provide nothing, so # they need no realm. The `fs` service and its policy stay in the host. - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' @@ -63,11 +63,6 @@ config: sampleOverCapGlobResults: false -- id: tool-str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 - # ── background tasks ──────────────────────────────────────────────────────── # Only the model-facing controls. The task REGISTRY stays on the host plane: diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index 1bc3d3bcf0..01969180ef 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -47,7 +47,7 @@ # ── filesystem ────────────────────────────────────────────────────────────── -# All three register into the host `tools` registry and provide nothing, so +# Both register into the host `tools` registry and provide nothing, so # they need no realm. The `fs` service and its policy stay in the host. - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' @@ -57,11 +57,6 @@ config: sampleOverCapGlobResults: false -- id: tool-str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 - # ── background tasks ──────────────────────────────────────────────────────── # Only the model-facing controls. The task REGISTRY stays on the host plane: diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 6442b12306..f46684888b 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -46,7 +46,7 @@ # ── filesystem ────────────────────────────────────────────────────────────── -# All three register into the host `tools` registry and provide nothing, so +# Both register into the host `tools` registry and provide nothing, so # they need no realm. The `fs` service and its policy stay in the host. - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' @@ -56,11 +56,6 @@ config: sampleOverCapGlobResults: false -- id: tool-str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 - # ── background tasks ──────────────────────────────────────────────────────── # Only the model-facing controls. The task REGISTRY stays on the host plane: diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index d36531c640..b47c44bec7 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -145,7 +145,7 @@ describe('the shipped Web composition', () => { expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'send_message', 'skill', - 'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill', + 'subagent', 'subagent_fork', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search', 'workflow', 'write', ]) @@ -217,6 +217,7 @@ describe('the shipped Web composition', () => { expect(tools).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount'])) // And it keeps the standard agent's own tools rather than replacing them. expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill'])) + expect(tools).not.toContain('str_replace_editor') // The preset's own authoring skill registers into ITS layer of the host // registry: the cordis agent's view carries it, the global view does not. @@ -243,9 +244,9 @@ describe('the shipped Web composition', () => { // the capabilities — so the assembly is what carries the claim. const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent }) expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code']) - expect(toolNames(ctx, coded.agent)).toContain('str_replace_editor') + expect(toolNames(ctx, coded.agent)).not.toContain('str_replace_editor') const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? '' - expect(sdk).toContain('str_replace_editor') + expect(sdk).not.toContain('str_replace_editor') expect(sdk).toContain('web_search') // The presentation is this agent's alone: the deployment default is 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 148/229] 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 <span className={css.triggerIcon} aria-hidden>{permissionGlyph(currentValue)}</span> )} <span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span> - {/* 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. */} - <IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} /> + {/* Same glyph + open rotation as the sibling ModelSelect trigger. */} + <span className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden> + <IconChevronDownOutline14 /> + </span> </button> } /> 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) => ( </svg> ) -/** 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) => ( <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <mask id="mask0_agent_preset_16" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"> 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 d65fe720806b856e043651f34ca5054d56c36a01 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 23:01:00 +0800 Subject: [PATCH 149/229] test(web): align shipped preset roster --- apps/web/tests/shipped-composition.e2e.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index faf01ede62..8e49fded4f 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -38,7 +38,6 @@ const EXPECTED_TOOLS = [ 'read', 'send_message', 'skill', - 'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill', 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 150/229] 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<string, unknown> { 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<string, unknown>, event: string): Record return workflow.on[event] } +function workflowJob(workflow: Record<string, unknown>, job: string): Record<string, unknown> { + 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<string, unknown> { 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 151/229] 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 152/229] 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 153/229] 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 788368e31410ed695fd5a10696a823dd9424b0e4 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 20:52:26 +0800 Subject: [PATCH 154/229] feat(cmdline): hand the launcher's remaining arguments to the app it boots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A launcher provides three values before the tree mounts: ctx.cmdlineArgs (get() is the whole interface) carrying everything after its own flags, ctx.appExit for a bounded exit, and ctx.appPatches for decisions a later recomposition must keep. An app's startup row injects cmdlineArgs and calls runStartup() with its own commander program. Rows the app configures inject its startup service, so they wait until the startup row has resolved their values and provided it; --help prints, disables those rows, and exits without the app ever starting. A changed row is recycled — disabled, then re-enabled with its new values — because a row's config is resolved when the Loader creates its fiber, while the row is still waiting. Recycling never touches inject: an inject update restarts the row from its unwrapped callback and loses the plugin's own static injections. A mount still in flight is allowed to settle first, so the disable has a fiber to dispose instead of racing one into existence. --- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 + docs/module-graph.zh.md | 3 + packages/boot/README.md | 3 +- packages/boot/README.zh.md | 3 +- packages/boot/cmdline/README.i18n.yaml | 6 + packages/boot/cmdline/README.md | 72 ++++ packages/boot/cmdline/README.zh.md | 72 ++++ packages/boot/cmdline/package.json | 42 +++ packages/boot/cmdline/src/index.ts | 311 ++++++++++++++++++ packages/boot/cmdline/src/invariant.ts | 34 ++ packages/boot/cmdline/tests/cmdline.spec.ts | 268 +++++++++++++++ packages/boot/cmdline/tsconfig.json | 24 ++ pnpm-lock.yaml | 19 ++ scripts/check-workspace-constraints.ts | 3 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.host.json | 1 + 17 files changed, 865 insertions(+), 4 deletions(-) create mode 100644 packages/boot/cmdline/README.i18n.yaml create mode 100644 packages/boot/cmdline/README.md create mode 100644 packages/boot/cmdline/README.zh.md create mode 100644 packages/boot/cmdline/package.json create mode 100644 packages/boot/cmdline/src/index.ts create mode 100644 packages/boot/cmdline/src/invariant.ts create mode 100644 packages/boot/cmdline/tests/cmdline.spec.ts create mode 100644 packages/boot/cmdline/tsconfig.json diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1f3d01750c..954f038557 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: 2b1f8dd9d41ab5ad34a4787ffa54c6b7d13144af -module-graph.zh.md: 192943312b672c1ae10d32182e9ccd7456eac90b +module-graph.md: 3efb73d075f3d5d7a8bae990fc2f524dc710bcb7 +module-graph.zh.md: df3b9b38497893471b2613c0c95da409dda0262b diff --git a/docs/module-graph.md b/docs/module-graph.md index 2b1f8dd9d4..3efb73d075 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -133,6 +133,7 @@ flowchart TD end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] + pkg_cmdline["cmdline"] end subgraph group_bundle["packages/bundle"] pkg_base["base"] @@ -313,6 +314,7 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_llm_mock_server --> pkg_invariants + pkg_cmdline --> pkg_invariants pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_schema_form --> pkg_invariants @@ -1252,6 +1254,7 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | +| [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 192943312b..df3b9b3849 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -135,6 +135,7 @@ flowchart TD end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] + pkg_cmdline["cmdline"] end subgraph group_bundle["packages/bundle"] pkg_base["base"] @@ -315,6 +316,7 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_llm_mock_server --> pkg_invariants + pkg_cmdline --> pkg_invariants pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_schema_form --> pkg_invariants @@ -1254,6 +1256,7 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | +| [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | diff --git a/packages/boot/README.md b/packages/boot/README.md index 5e4e483b60..58a824a7f4 100644 --- a/packages/boot/README.md +++ b/packages/boot/README.md @@ -7,5 +7,6 @@ The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/ | Package | Role | ctx key | |---|---|---| | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | +| `cmdline/` | Launcher-to-app command-line handoff and app-owned startup parsing | `cmdlineArgs`, `appExit`, `appReady` | -The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md). +The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md); app-owned command lines are documented in [`cmdline/README.md`](cmdline/README.md). diff --git a/packages/boot/README.zh.md b/packages/boot/README.zh.md index 95a3f98129..7357b920a0 100644 --- a/packages/boot/README.zh.md +++ b/packages/boot/README.zh.md @@ -7,5 +7,6 @@ | 包 | 职责 | ctx 键 | |---|---|---| | `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | +| `cmdline/` | 启动器到应用的命令行交接,以及由应用持有的启动解析 | `cmdlineArgs`、`appExit`、`appReady` | -启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md)。 +启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md);由应用持有的命令行见 [`cmdline/README.md`](cmdline/README.md)。 diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml new file mode 100644 index 0000000000..f1e5a30951 --- /dev/null +++ b/packages/boot/cmdline/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/ui/cmdline/README.md +README.md: 3d7aa7fd58c7e542ac0c733eb0794436cb0fc42d +README.zh.md: d6eb191e1c0c8136a613d5e9fe29bb66420139ac diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md new file mode 100644 index 0000000000..3d7aa7fd58 --- /dev/null +++ b/packages/boot/cmdline/README.md @@ -0,0 +1,72 @@ +# `@deepseek-ai/dsh-cmdline` + +English | [中文](README.zh.md) + +The command line a dsh launcher hands to the app it boots. The launcher parses only its own flags (`--profile`, `--patch`, the config dumps) and hands **everything after them** to the tree verbatim, so an app owns its flag family, its `--help` text, and its parse errors instead of the launcher knowing them. + +## The three launcher values + +A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which provides: + +- `ctx.cmdlineArgs` — the invocation's inner arguments. `get()` is the whole interface, and it returns a snapshot: `dsh --profile tui --resume abc` yields `['--resume', 'abc']`. +- `ctx.appExit` — a bounded process-exit request, wired to the launcher's shutdown controller. +- `ctx.appPatches` — where a startup row records its decisions, for a launcher that recomposes its tree. Omitted by a host that never does. + +An embedding host with no command line provides an empty list; that is the honest answer, not a missing value. + +## Startup rows and the services their rows wait for + +An app reads those arguments from a **startup row** — a plugin that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`: + +```ts ignore +export const name = 'web-startup' +export const inject = ['cmdlineArgs'] + +export function apply(ctx: Context): Promise<void> { + return runStartup(ctx, 'webStartup', webCommand(), planWebStartup) +} +``` + +Every row the app configures from flags injects that startup service in the bundle patch: + +```yaml +- id: webserver + name: '@deepseek-ai/dsh-host-webserver' + inject: [webStartup] + config: + host: 127.0.0.1 + port: 3080 +``` + +`runStartup` parses the arguments, asks `plan` what each waiting row's values should be, applies them, and provides the startup service, which is what lets those rows start. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, disables the waiting rows, and requests exit — the app never starts, and the settlement audit sees a tree that was asked not to start it. + +`plan` receives every waiting row's **composed** options, so a decision reads what the bundle patches and the user's own layers agreed on before overriding it; `overrideConfig(row, { port })` replaces exactly the named keys. A row absent from the plan starts on its composed values, and planning a change for a row also enables it. + +A row whose required config the startup **supplies** rather than overrides must ship `disabled: true`, because a waiting row's config is validated when its fiber is created — before the startup service arrives — and a missing required key fails the boot there. The one-shot runner's `task` is the shipped example. A row shipped disabled for another reason is turned on the same way: `dsh web --dev` plans `{ disabled: false }` for the HMR receiver. + +The decisions also reach the launcher through `ctx.appPatches`, which is what keeps them alive across a recomposition: without it, a user editing a live patch file would rebuild every row from its composed options and silently move a server started on `--port 8080` back to the composed port. + +### Why a changed row is recycled + +A waiting row's config is resolved when the Loader creates its fiber, which happens while the row is still waiting. Writing a new config onto that fiber never reaches the plugin, so each changed row is disabled and re-enabled, which drops the stale fiber and resolves the config again. A row whose own mount is still in flight is allowed to settle first, so the disable has a fiber to dispose instead of racing one into existence. + +Recycling deliberately leaves `inject` alone. Updating a row's `inject` restarts it from its unwrapped callback, which loses the plugin's own static injections — a row that declares `inject = ['httpServer', 'apiProxy']` would come back unable to read either. + +### One command line, one owner + +A composition has exactly one command-line owner. An app that layers over another one disables the underlying startup row and names both startup services, so the rows it absorbed start on their composed values — [`dsh-headless`](../../bundle/headless/README.md) does this over [`dsh-web-app`](../../bundle/web-app/README.md). + +An out-of-tree plugin brings its own commander copy, so commander's control-flow errors are detected structurally rather than by class identity; an identity check would rethrow a printed help as a fatal load failure. + +## Model Experience + +None, as this package resolves the process's own command line before any session exists. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Launcher flags must precede app arguments.** The split is positional: the first token the launcher does not recognize starts the inner arguments, so `--patch` placed after an app flag belongs to the app. The launcher's parser consumes one `--`, so an app argument that must survive as a literal `--` needs `-- --`. +- **A startup service has no declared owner.** The rows name it and a startup row provides it; nothing links the two statically, so a bundle that ships waiting rows without its startup row fails at settlement (pending entries naming the service) rather than at load. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md new file mode 100644 index 0000000000..d6eb191e1c --- /dev/null +++ b/packages/boot/cmdline/README.zh.md @@ -0,0 +1,72 @@ +# `@deepseek-ai/dsh-cmdline` + +[English](README.md) | 中文 + +dsh 启动器交给它所引导应用的那条命令行。启动器只解析属于自己的 flag(`--profile`、`--patch`、配置 dump),并把**其后的一切**原样交给配置树,因此 flag 家族、`--help` 文本和解析错误都由应用自己持有,启动器不必知道它们。 + +## 启动器提供的三个值 + +启动器在任何配置树条目挂载之前调用 `provideCmdline(ctx, host)`,它提供: + +- `ctx.cmdlineArgs`:本次调用的内层参数。`get()` 就是它的全部接口,返回一份快照:`dsh --profile tui --resume abc` 得到 `['--resume', 'abc']`。 +- `ctx.appExit`:一个有边界的进程退出请求,接到启动器的关停控制器上。 +- `ctx.appPatches`:启动行记录自身决策的去处,面向会重新组合自己配置树的启动器。从不重新组合的宿主不提供它。 + +没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 + +## 启动行,以及各行所等待的服务 + +应用从**启动行**读取这些参数:启动行是一个注入 `cmdlineArgs` 并调用 `runStartup(ctx, service, program, plan)` 的插件: + +```ts ignore +export const name = 'web-startup' +export const inject = ['cmdlineArgs'] + +export function apply(ctx: Context): Promise<void> { + return runStartup(ctx, 'webStartup', webCommand(), planWebStartup) +} +``` + +应用用 flag 配置的每一行,都在组合包 patch 中注入那个启动服务: + +```yaml +- id: webserver + name: '@deepseek-ai/dsh-host-webserver' + inject: [webStartup] + config: + host: 127.0.0.1 + port: 3080 +``` + +`runStartup` 解析参数,向 `plan` 询问每个等待中的行应有的取值,应用这些取值,然后提供启动服务,正是这一步让这些行得以启动。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本,禁用等待中的行并请求退出:应用从不启动,结算审计看到的是一棵被要求不要启动它的树。 + +`plan` 收到的是每个等待中的行**组合后**的选项,因此决策在覆盖之前能读到组合包 patch 与用户自己那几层达成的结果;`overrideConfig(row, { port })` 只替换点名的那些配置键。plan 中未出现的行按组合后的取值启动;而为某一行 plan 了改动,也会顺带启用它。 + +必填配置由启动流程**供给**而非覆盖的行,必须以 `disabled: true` 交付,因为等待中的行的配置在其 fiber 创建时就会被校验(此时启动服务尚未到达),缺少一个必填键会在那里就让 boot 失败。一次性运行器的 `task` 就是随附的例子。因其他原因以禁用状态交付的行也以同样方式打开:`dsh web --dev` 为 HMR(热模块替换)接收方 plan 了一个 `{ disabled: false }`。 + +这些决策同时经 `ctx.appPatches` 到达启动器,正是这一点让它们在一次重新组合中存活下来:没有它,用户编辑一个活动的 patch 文件就会把每一行都从其组合后的选项重建出来,并悄悄把一台以 `--port 8080` 启动的服务器挪回组合后的端口。 + +### 为什么改动过的行要回收重建 + +等待中的行的配置在 Loader 创建它的 fiber 时就已解析,而这发生在该行仍在等待的时候。把新配置写到这个 fiber 上,永远到不了插件,因此每个改动过的行都会先禁用再重新启用,从而丢弃陈旧的 fiber 并重新解析配置。自身挂载仍在进行中的行会先被放行至停稳,这样禁用时才有一个 fiber 可供 dispose(资源释放),而不是与一个正在诞生的 fiber 抢跑。 + +回收重建刻意不动 `inject`。更新一行的 `inject` 会让它从未经包装的回调重新启动,从而丢失插件自身的静态注入:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。 + +### 一条命令行,一个所有者 + +一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的启动行,并同时点名两个启动服务,使它吸收过来的行按组合后的取值启动:[`dsh-headless`](../../bundle/headless/README.md) 相对 [`dsh-web-app`](../../bundle/web-app/README.md) 就是这么做的。 + +树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。 + +## 模型体验 + +无。本包在任何会话存在之前解析进程自身的命令行。 + +#### KV Cache 影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + +- **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。 +- **启动服务没有声明所有者**:各行点名它,由启动行提供它;两者之间没有静态关联,因此交付了等待中的行却缺少对应启动行的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。 diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json new file mode 100644 index 0000000000..7ac7f488d9 --- /dev/null +++ b/packages/boot/cmdline/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-cmdline", + "description": "Command-line seam between a dsh launcher and surface bundles: the cmdlineArgs service exposing the invocation's inner arguments, the startup host for contributing flag-derived config patches, and the commander adapter startup plugins share", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "dependencies": { + "commander": "^15.0.0" + }, + "peerDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts new file mode 100644 index 0000000000..3dce71079e --- /dev/null +++ b/packages/boot/cmdline/src/index.ts @@ -0,0 +1,311 @@ +/** + * @deepseek-ai/dsh-cmdline — the command line a dsh launcher hands to the app + * it boots. + * + * The launcher parses only its own flags (`--profile`, `--patch`, the config + * dumps) and hands everything after them to the tree verbatim through the + * {@link CmdlineArgs} service, so an app owns its flag family, its `--help` + * text, and its parse errors instead of the launcher knowing them. + * + * An app consumes those arguments from a **startup plugin**: a row that + * injects `cmdlineArgs` and calls {@link runStartup}. Every row the app + * configures from flags declares `inject: [<startup service>]` in the bundle + * patch and therefore waits until the startup plugin provides that service; + * `--help` prints, disables exactly those rows, and requests exit, so the app + * never starts. + * @module @deepseek-ai/dsh-cmdline + */ + +import type { Command } from 'commander' +import type { Context } from 'cordis' +import type { PatchOptions } from '@cordisjs/plugin-include' +import type { Entry, EntryOptions } from '@cordisjs/plugin-loader' +// Empty type import carries the loader Context merge used to walk the tree. +import type {} from '@cordisjs/plugin-loader' + +/** + * The invocation's inner arguments: everything after the launcher's own flags, + * verbatim and in argv order. `dsh --profile tui --resume abc` yields + * `['--resume', 'abc']`. + */ +export interface CmdlineArgs { + /** + * Read the inner arguments. + * @returns the arguments in argv order; empty when the invocation carried none. + */ + get(): readonly string[] +} + +/** Request bounded process exit; the launcher wires it to its shutdown controller. */ +export interface AppExit { + /** + * Request exit once the tree has been disposed. + * @param code - the process exit code. + */ + (code: number): void +} + +/** + * The launcher's own patch layer, above every layer a user can edit. + * + * A startup row's decisions are facts about this invocation, so they must + * outlive a recomposition of the tree: a launcher that re-applies its patch + * stack when the user edits a live patch file rebuilds every row from its + * composed options, which would otherwise silently reset a flag-configured + * row (a browser served on `--port 8080` would move back to the composed + * port on an unrelated edit). + */ +export interface AppPatches { + /** + * Record patches the launcher must keep applying on every later composition. + * @param patches - the startup row's decisions, as patches over the composed rows. + */ + contribute(patches: readonly PatchOptions[]): void +} + +declare module 'cordis' { + interface Context { + /** The invocation's inner arguments; provided by a launcher before the tree mounts. */ + cmdlineArgs?: CmdlineArgs + /** Bounded process-exit request; provided by a launcher before the tree mounts. */ + appExit?: AppExit + /** The launcher's own patch layer; provided by a launcher that recomposes its tree. */ + appPatches?: AppPatches + } +} + +/** The launcher facts an app's startup row needs. */ +export interface CmdlineHost { + /** The invocation's inner arguments, in argv order. */ + args: readonly string[] + /** Bounded process-exit request. */ + exit: AppExit + /** + * Sink for startup decisions a later recomposition must keep. A launcher + * that never recomposes its tree (a one-shot embedding host) omits it. + */ + contribute?: AppPatches['contribute'] +} + +/** + * Provide the command line, the exit request, and the patch sink on a host + * context before any tree entry mounts. These are launcher facts, not config: + * an embedding host with no command line provides an empty argument list. + * @param ctx - the host context the tree will mount under. + * @param host - the invocation's arguments, exit request, and optional patch sink. + */ +export function provideCmdline(ctx: Context, host: CmdlineHost): void { + const snapshot = [...host.args] + ctx.provide('cmdlineArgs', { get: () => snapshot }) + ctx.provide('appExit', host.exit) + const contribute = host.contribute + if (contribute !== undefined) ctx.provide('appPatches', { contribute }) +} + +/** The process streams commander output is written to; production writes to the process. */ +export const internals: { stdout: { write(chunk: string): unknown }; stderr: { write(chunk: string): unknown } } = { + stdout: process.stdout, + stderr: process.stderr, +} + +/** + * What a startup plugin changes on one waiting row. A row with a change is + * re-enabled as part of applying it; `{ disabled: true }` keeps it off (and + * `{ disabled: false }` is how a row a bundle ships disabled gets turned on). + */ +export type RowChange = Omit<Partial<EntryOptions>, 'id' | 'inject'> + +/** + * Decide this invocation's changes for the rows waiting on an app's startup + * service. + * + * Runs after a successful parse, with every waiting row's composed options + * (bundle layers, the user's layers, and any `--patch` overlay already + * applied), so a decision can read what the composition agreed on before + * overriding it. Call `program.error(...)` to reject the invocation with a + * usage message instead of throwing. + * @param program - the parsed commander program. + * @param rows - the waiting rows' composed options, in tree order. + * @returns row id → the changes for that row; ids absent from the map start unchanged. + */ +export type StartupPlan = (program: Command, rows: readonly EntryOptions[]) => Map<string, RowChange> + +/** + * Run one app's startup: parse the invocation's inner arguments with the app's + * own commander program, apply the resulting changes to the waiting rows, and + * release them by providing the startup service they inject. + * + * A waiting row's config is resolved when the Loader creates its fiber, which + * happens while the row is still waiting, so writing a new config onto that + * fiber would never reach the plugin. Each changed row is therefore recycled — + * disabled, then re-enabled with its new values — which drops the stale fiber + * and resolves the config again. Recycling deliberately leaves `inject` alone: + * an `inject` update restarts the row from its unwrapped callback and loses the + * plugin's own static injections. + * + * Help, version, and rejected arguments are terminal for the process: the text + * is written, every waiting row is disabled so the settlement audit sees a tree + * that was asked not to start this app, and `ctx.appExit` is requested. + * + * An app that layers over another one (the one-shot bundle rides over the web + * bundle) disables the underlying startup row and names both startup services, + * because a composition has exactly one command-line owner: the rows of the app + * it absorbed then start on their composed values. + * @param ctx - plugin context carrying `cmdlineArgs`, `appExit`, and the Loader. + * @param services - the startup service name, or names, that this app's rows declare in their `inject`. + * @param program - the app's commander program, with its flags and description already declared. + * @param plan - this invocation's per-row changes; omitted starts the waiting rows unchanged. + * @returns nothing once the waiting rows are released, or once the exit was requested. + * @throws when the launcher provided no command line, when a startup service is + * declared by no row, or when `plan` names a row that is not waiting. + */ +export async function runStartup( + ctx: Context, + services: string | readonly string[], + program: Command, + plan: StartupPlan = () => new Map(), +): Promise<void> { + const names = typeof services === 'string' ? [services] : services + // Read through the global service store, not the property proxy: these are + // optional host values, and a row that injects only `cmdlineArgs` may not + // read the others as declared injections. + const args = ctx.get('cmdlineArgs') + const exit = ctx.get('appExit') + if (args === undefined || exit === undefined) { + throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`) + } + program + .exitOverride() + .configureOutput({ + writeOut: text => void internals.stdout.write(text), + writeErr: text => void internals.stderr.write(text), + }) + let decisions: Map<string, RowChange> + let rows: EntryOptions[] + try { + program.parse(args.get(), { from: 'user' }) + // An app can dispose the whole tree while this row is still parsing (an + // early SIGTERM, or another app exiting). There is then nothing to + // configure and nothing to release, and the checks below would blame the + // bundle for a tree that simply went away. + if (ctx.get('loader') === undefined) return + rows = waitingRows(ctx, names) + decisions = plan(program, rows) + } catch (error) { + // exitOverride turns help, version, a parse error, and a plan's own + // program.error() into a CommanderError; commander has already written the + // text through the output configured above. + if (!isCommanderError(error)) throw error + for (const entry of waitingEntries(ctx, names)) await stopRow(entry) + exit(error.exitCode) + return + } + const unknown = [...decisions.keys()].filter(id => !rows.some(row => row.id === id)) + if (unknown.length > 0) { + throw new Error(`${program.name()}: startup planned changes for row(s) ${unknown.join(', ')}, which inject none of ${names.join(', ')}`) + } + const contributed: PatchOptions[] = [] + for (const entry of waitingEntries(ctx, names)) { + const change = decisions.get(entry.options.id) + if (change === undefined) continue + await stopRow(entry) + await entry.update({ disabled: false, ...change }) + contributed.push({ id: entry.options.id, disabled: false, ...change }) + } + // Hand the same decisions to the launcher as patches, so a later + // recomposition of the tree (a user editing a live patch file) rebuilds + // these rows with this invocation's values instead of the composed ones. + if (contributed.length > 0) ctx.get('appPatches')?.contribute(contributed) + // The rows are ready; providing the service they inject starts them, and a + // row this invocation left disabled stays that way. + for (const service of names) ctx.provide(service, true) +} + +/** + * Stop a waiting row, including one whose own mount is still in flight. + * + * Disabling alone is not a barrier: a row whose init has not finished has no + * fiber yet, so the update returns while that init goes on to create one, and + * the re-enable would then take the config-patch path, which a still-waiting + * fiber never applies — the row would start on stale values. Letting the mount + * settle first gives the disable a fiber to dispose. A row the composition + * ships disabled has no mount to settle and is left alone. + * @param entry - the waiting row's Loader entry. + */ +async function stopRow(entry: Entry): Promise<void> { + await entry.refresh() + await entry.update({ disabled: true }) +} + +/** + * Merge flag overrides over a waiting row's composed config. + * + * A row's composed config is what the bundle patches and the user's own layers + * agreed on; a flag replaces exactly the keys it names and leaves the rest of + * that agreement intact. + * @param options - the waiting row's composed options. + * @param overrides - the values this invocation's flags decided, by config key. + * @returns the change to put in a {@link StartupPlan}'s map. + */ +export function overrideConfig(options: EntryOptions, overrides: Record<string, unknown>): RowChange { + return { config: { ...(options.config ?? {}) as Record<string, unknown>, ...overrides } } +} + +/** + * The composed options of every row waiting on one of `services`, in tree order. + * @param ctx - plugin context whose Loader tree carries the rows. + * @param services - the startup service names. + * @returns the waiting rows' options. + * @throws when a startup service is declared by no row, which means the bundle + * patch and its startup plugin disagree. + */ +function waitingRows(ctx: Context, services: readonly string[]): EntryOptions[] { + for (const service of services) { + if (waitingEntries(ctx, [service]).length === 0) { + throw new Error(`${service}: no row injects this startup service — the bundle patch must set "inject: [${service}]" on every row this app configures`) + } + } + return waitingEntries(ctx, services).map(entry => entry.options) +} + +/** + * The Loader entries waiting on any of `services`. + * @param ctx - plugin context whose Loader tree carries the rows. + * @param services - the startup service names. + * @returns the waiting entries in tree order. + */ +function waitingEntries(ctx: Context, services: readonly string[]): Entry[] { + // Called only after runStartup established the tree is still live. + return [...ctx.loader.entries()].filter(entry => services.some(service => waitsFor(entry.options.inject, service))) +} + +/** + * Whether a thrown value is commander's own control-flow error (help, version, + * a parse error, or `program.error`). + * + * Detected structurally, not with `instanceof`: an out-of-tree plugin brings + * its own commander copy, whose `CommanderError` class is a different identity + * from this package's, and an identity check there would rethrow a printed + * help as a fatal load failure. + * @param error - the thrown value. + * @returns true when the value carries commander's error code and exit code. + */ +function isCommanderError(error: unknown): error is { code: string; exitCode: number } { + if (typeof error !== 'object' || error === null) return false + const candidate = error as { code?: unknown; exitCode?: unknown } + return typeof candidate.code === 'string' && candidate.code.startsWith('commander.') + && typeof candidate.exitCode === 'number' +} + +/** + * Whether a row's `inject` declaration names `service`. + * @param inject - the row's `inject` value: the array form, the object form, or absent. + * @param service - the startup service name. + * @returns true when the row waits for it. + */ +function waitsFor(inject: EntryOptions['inject'], service: string): boolean { + if (inject === undefined || inject === null) return false + // The array form lists service names; the object form maps each name to its + // intercept config. Both name the service as a key of the same shape. + return Array.isArray(inject) ? inject.includes(service) : Object.hasOwn(inject, service) +} diff --git a/packages/boot/cmdline/src/invariant.ts b/packages/boot/cmdline/src/invariant.ts new file mode 100644 index 0000000000..f1ec75678f --- /dev/null +++ b/packages/boot/cmdline/src/invariant.ts @@ -0,0 +1,34 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-cmdline`. + * @module @deepseek-ai/dsh-cmdline/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-cmdline' + +/** Cordis companion plugin name. */ +export const name = 'cmdline-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the owned relation is "no row is left waiting for a + * startup service", which is a property of the whole tree at Loader + * settlement, and the invariant service carries no settlement signal to + * evaluate it at. Observing it from the entry stream would fire while startup + * is still parsing, when every waiting row is legitimately still waiting. The + * launcher's post-settlement audit (`assertEntriesActivated`) already reports + * a startup service that was never provided as a pending entry naming it, and + * the built-bin e2e asserts the apps boot with flag values applied. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts new file mode 100644 index 0000000000..b4ea1a3624 --- /dev/null +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -0,0 +1,268 @@ +/** + * The launcher-to-app command line over a REAL Loader tree: a startup row parses the + * invocation's inner arguments and releases the rows waiting for it, waiting rows start + * with the resolved values, `--help` leaves the app unstarted, and a + * bundle whose patch and startup plugin disagree fails loud. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Command } from 'commander' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { afterEach, describe, expect, it } from 'vitest' +import { internals, overrideConfig, provideCmdline, runStartup, type RowChange, type StartupPlan } from '../src/index.ts' + +/** Every value one boot of the fixture tree observed. */ +interface Observed { + applied: { id: string; config: Record<string, unknown> }[] + exits: number[] + out: string +} + +/** A booted fixture tree: what it observed, and its root for direct startup calls. */ +interface Fixture { + observed: Observed + ctx: Context + /** Patches the startup row handed the launcher for later compositions. */ + contributed: unknown[] +} + +/** Cordis FiberState.ACTIVE, mirrored because the const enum has no runtime object. */ +const FIBER_ACTIVE = 2 + +const disposers: (() => Promise<void>)[] = [] + +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** The fixture's flag family: one `--port` over the waiting row's composed config. */ +function demoCommand(): Command { + return new Command().name('demo').exitOverride().option('--port <port>', 'listen port') +} + +/** The fixture's plan: `--port` overrides the waiting row, absent leaves it composed. */ +const demoPlan: StartupPlan = (program, rows) => { + const port = program.opts<{ port?: string }>().port + if (port === undefined) return new Map() + if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`) + const row = rows.find(candidate => candidate.id === 'waiting') + return new Map(row === undefined ? [] : [['waiting', overrideConfig(row, { port: Number(port) })]]) +} + +/** + * Mount a tree with one waiting row, and — unless the caller drives startup + * itself — a startup row that calls {@link runStartup} on this package's real + * code path. + * @param args - the invocation's inner arguments. + * @param options - fixture knobs for the shapes a bundle patch can produce. + * @returns the booted fixture. + */ +async function bootFixture( + args: string[], + options: { injectObjectForm?: boolean; withoutStartupRow?: boolean; slowWaitingImport?: boolean } = {}, +): Promise<Fixture> { + const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) + const observed: Observed = { applied: [], exits: [], out: '' } + writeFileSync(join(dir, 'waiting.mjs'), ` +${options.slowWaitingImport === true ? 'await new Promise(resolve => setTimeout(resolve, 30))' : ''} +export const name = 'waiting' +export function apply(ctx, config) { globalThis.__observed.applied.push({ id: 'waiting', config }) } +`) + // The Loader imports a row through Node's own resolver, which cannot resolve + // this workspace's sources; the row delegates to the real function the test + // imported through the source-plane path mapping. + writeFileSync(join(dir, 'startup.mjs'), ` +export const name = 'startup' +export const inject = ['cmdlineArgs'] +export function apply(ctx) { return globalThis.__runStartup(ctx) } +`) + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: waiting', + ` name: ${pathToFileURL(join(dir, 'waiting.mjs')).href}`, + options.injectObjectForm === true ? ' inject: { demoStartup: null }' : ' inject: [demoStartup]', + ' config:', + ' port: 3080', + ' host: 127.0.0.1', + ...options.withoutStartupRow === true ? [] : [ + '- id: startup', + ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, + ], + '', + ].join('\n')) + const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + internals.stdout = observing + internals.stderr = observing + const globals = globalThis as unknown as { __observed: Observed; __runStartup: (ctx: Context) => Promise<void> } + globals.__observed = observed + globals.__runStartup = (ctx: Context) => runStartup(ctx, 'demoStartup', demoCommand(), demoPlan) + + const contributed: unknown[] = [] + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + provideCmdline(ctx, { + args, + exit: code => void observed.exits.push(code), + contribute: patches => void contributed.push(...patches), + }) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) + await ctx.loader.await() + disposers.push(async () => { await ctx.fiber.dispose() }) + return { observed, ctx, contributed } +} + +describe('runStartup', () => { + it('starts a waiting row only after the startup service arrives, with the flag value applied over its composed config', async () => { + const { observed } = await bootFixture(['--port', '8080']) + expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) + expect(observed.exits).toEqual([]) + }) + + it('starts the waiting row unchanged when the invocation carries no flags', async () => { + const { observed } = await bootFixture([]) + expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 3080, host: '127.0.0.1' } }]) + }) + + it('applies the flag value to a row whose own mount was still in flight', async () => { + // The row has no fiber yet when startup disables it, so the disable is not + // a barrier: the in-flight mount still produces one. Without disposing + // that late fiber, the row would start on its composed port. + const { observed } = await bootFixture(['--port', '8080'], { slowWaitingImport: true }) + expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) + }) + + it('starts a row that injects the startup service in the intercept-map form of inject', async () => { + const { observed } = await bootFixture(['--port', '8080'], { injectObjectForm: true }) + expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) + }) + + it('prints the app help, leaves the app unstarted, and requests exit 0', async () => { + const { observed } = await bootFixture(['--help']) + expect(observed.out).toContain('Usage: demo') + expect(observed.applied).toEqual([]) + expect(observed.exits).toEqual([0]) + }) + + it('rejects the invocation from the plan without starting the app', async () => { + const { observed } = await bootFixture(['--port', 'abc']) + expect(observed.out).toContain('--port must be a number') + expect(observed.applied).toEqual([]) + expect(observed.exits).toEqual([1]) + }) +}) + +describe('startup-service lifetime', () => { + it('unloads the waiting rows when the startup row is disposed, and reopens on a fresh run', async () => { + // The startup service is an effect of the startup row: HMR restarting that + // row must take its app down with it, then bring it back. + const { ctx, observed } = await bootFixture(['--port', '8080']) + const startup = [...ctx.loader.entries()].find(entry => entry.options.id === 'startup') + const waiting = [...ctx.loader.entries()].find(entry => entry.options.id === 'waiting') + expect(waiting?.fiber?.state).toBe(FIBER_ACTIVE) + await startup?.update({ disabled: true }) + expect(waiting?.fiber?.state).not.toBe(FIBER_ACTIVE) + await startup?.update({ disabled: false }) + await ctx.loader.await() + expect(waiting?.fiber?.state).toBe(FIBER_ACTIVE) + // The second run re-resolved the same arguments, so the row is back on the + // flag value rather than the composed one. + expect(observed.applied.at(-1)).toEqual({ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }) + }) +}) + +describe('runStartup rejects a bundle that disagrees with its own patch', () => { + it('fails when no row declares the startup service it provides', async () => { + // The patch and its startup plugin disagree; a silent no-op would leave + // the app's rows waiting forever with no explanation. + const { ctx } = await bootFixture([], { withoutStartupRow: true }) + await expect(runStartup(ctx, 'absentStartup', demoCommand(), demoPlan)) + .rejects.toThrow('absentStartup: no row injects this startup service') + }) + + it('fails when the plan names a row that is not waiting', async () => { + const { ctx, observed } = await bootFixture([], { withoutStartupRow: true }) + const plan: StartupPlan = () => new Map<string, RowChange>([['not-waiting', {}]]) + await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)) + .rejects.toThrow('startup planned changes for row(s) not-waiting') + expect(observed.applied).toEqual([]) + }) + + it('rethrows a plan failure that is not commander asking to exit', async () => { + const { ctx, observed } = await bootFixture([], { withoutStartupRow: true }) + const plan: StartupPlan = () => { throw new Error('plan exploded') } + await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)).rejects.toThrow('plan exploded') + expect(observed.exits).toEqual([]) + }) + + it('rethrows a thrown value that is not an object at all', async () => { + const { ctx } = await bootFixture([], { withoutStartupRow: true }) + const plan: StartupPlan = () => { + const thrown: unknown = 'plan threw a string' + throw thrown + } + await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)).rejects.toThrow('plan threw a string') + }) +}) + +describe('the launcher patch layer', () => { + it('hands the startup row\'s decisions to the launcher as patches', async () => { + const { contributed } = await bootFixture(['--port', '8080']) + // The same decisions the rows started with: a launcher that recomposes its + // tree re-applies these, so an unrelated user edit cannot reset the port. + expect(contributed).toEqual([ + { id: 'waiting', disabled: false, config: { port: 8080, host: '127.0.0.1' } }, + ]) + }) + + it('contributes nothing when the invocation decided nothing', async () => { + const { contributed } = await bootFixture([]) + expect(contributed).toEqual([]) + }) +}) + +describe('an app with nothing to decide', () => { + it('starts every waiting row unchanged when it declares no plan', async () => { + const { ctx, observed } = await bootFixture([], { withoutStartupRow: true }) + // The list form of the service argument, which an app layering over + // another one uses to absorb that app's startup service. + await runStartup(ctx, ['demoStartup'], demoCommand()) + expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 3080, host: '127.0.0.1' } }]) + }) + + it('overrides a row that carries no composed config', () => { + expect(overrideConfig({ id: 'row', name: 'plugin' }, { port: 8080 })).toEqual({ config: { port: 8080 } }) + }) +}) + +describe('provideCmdline', () => { + it('hands the app a snapshot the caller cannot mutate afterwards', () => { + const ctx = new Context() + const args = ['--resume', 'abc'] + provideCmdline(ctx, { args, exit: () => {} }) + args.push('--tampered') + expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) + }) + + it('fails loud when a startup row runs without the launcher values', async () => { + const ctx = new Context() + await expect(runStartup(ctx, 'demoStartup', demoCommand())) + .rejects.toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit') + }) + + it('opens nothing, and blames nobody, when the tree was disposed while startup was parsing', async () => { + // An early SIGTERM disposes the Loader mid-parse. There is nothing left to + // open, and the bundle did nothing wrong. + const exits: number[] = [] + const ctx = new Context() + provideCmdline(ctx, { args: [], exit: code => void exits.push(code) }) + await expect(runStartup(ctx, 'demoStartup', demoCommand())).resolves.toBeUndefined() + expect(exits).toEqual([]) + }) +}) diff --git a/packages/boot/cmdline/tsconfig.json b/packages/boot/cmdline/tsconfig.json new file mode 100644 index 0000000000..f4bcebf1e8 --- /dev/null +++ b/packages/boot/cmdline/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/include" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df45d792b2..3d890741ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1175,6 +1175,25 @@ importers: specifier: ^4.0.9 version: 4.0.9 + packages/boot/cmdline: + dependencies: + commander: + specifier: ^15.0.0 + version: 15.0.0 + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bundle/base: dependencies: '@deepseek-ai/cordis-plugin-hmr': diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 0fcc4c16aa..49ff148922 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -165,6 +165,9 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { ...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [], // web-react's store subpath ships its own bundle (single-entry builds; no shared chunk). ...exportDefault(manifest, './store') === './lib/store/index.js' ? ['lib/store/index.js'] : [], + // A surface bundle's startup row is its own bundle: the Loader imports it + // as a row module, so it cannot ride inside the package entry. + ...exportDefault(manifest, './startup') === './lib/startup.js' ? ['lib/startup.js'] : [], ...extras, // Subpaths whose runtime default is the tsc-emitted tree (lib/types/*.js — // browser-safe source channels rehomed off src so plain Node can import diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 2b962ca471..1ffc0384f3 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -146,6 +146,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = { 'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' }, 'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' }, 'packages/boot/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' }, + 'packages/boot/cmdline': { kind: 'none', reason: 'Resolves the process command line before any session exists; configured rows own every model-visible consequence.' }, 'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' }, 'packages/interaction/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' }, 'packages/interaction/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 11f0ba7a88..32ae7df42d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -222,6 +222,7 @@ { "path": "./packages/bundle/headless" }, { "path": "./packages/bundle/web-app" }, { "path": "./packages/boot/app-boot" }, + { "path": "./packages/boot/cmdline" }, { "path": "./packages/scaffold/server" }, { "path": "./packages/examples/jsonrpc-demo" }, { "path": "./packages/support/llm-replay" }, From 82728808d49b0ccd59c0e88993eb28d5f525c418 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 20:52:26 +0800 Subject: [PATCH 155/229] feat(bundle): the web and one-shot apps own their own flags dsh-web-app owns --host/--port/--dev/--workspace-root/--trusted-host and its --help in a web-startup row; the rows it configures wait for the webStartup service, and the client-plugin HMR receiver now ships disabled so --dev is a row toggle rather than a runtime insert (the Loader cannot resolve a row inserted from inside a mounting plugin). dsh-headless owns the task positional and rejects a missing task as its own usage error. Its runner ships disabled, not merely waiting: the schema requires the task, and a row's config is validated when its fiber is created, before the startup row can supply one. A composition has exactly one command-line owner, so the patch disables the web startup row and this one provides webStartup too, leaving the web rows on their composed one-shot values. The keyless web scaffold provides the same three values with no arguments, which is what an embedding host with no command line does. --- apps/cli/tests/web-agent-presets.e2e.ts | 5 +- apps/web/package.json | 1 + apps/web/tests/scaffold.ts | 15 +- apps/web/tests/smoke-real.e2e.ts | 5 +- packages/boot/cmdline/package.json | 12 +- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 4 +- packages/bundle/headless/README.zh.md | 4 +- packages/bundle/headless/cordis.patch.yml | 14 +- packages/bundle/headless/package.json | 12 +- packages/bundle/headless/src/startup.ts | 70 ++++++++ .../bundle/headless/tests/startup.spec.ts | 146 ++++++++++++++++ packages/bundle/headless/tsconfig.json | 6 + packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 26 ++- packages/bundle/web-app/package.json | 11 +- packages/bundle/web-app/src/startup.ts | 152 ++++++++++++++++ packages/bundle/web-app/tests/startup.spec.ts | 163 ++++++++++++++++++ .../web-app/tests/trusted-hosts.spec.ts | 33 ++++ packages/bundle/web-app/tsconfig.json | 6 + pnpm-lock.yaml | 31 +++- 23 files changed, 692 insertions(+), 36 deletions(-) create mode 100644 packages/bundle/headless/src/startup.ts create mode 100644 packages/bundle/headless/tests/startup.spec.ts create mode 100644 packages/bundle/web-app/src/startup.ts create mode 100644 packages/bundle/web-app/tests/startup.spec.ts create mode 100644 packages/bundle/web-app/tests/trusted-hosts.spec.ts diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 20543965dc..85c5227bd4 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' @@ -100,7 +101,9 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis await mkdir(profileDir, { recursive: true }) const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') - return await boot('dsh-test', rootConfig, patches) + return await boot('dsh-test', rootConfig, patches, (bootCtx) => { + provideCmdline(bootCtx, { args: [], exit: () => {} }) + }) } const toolNames = (ctx: Context, agent?: Agent): string[] => diff --git a/apps/web/package.json b/apps/web/package.json index 6f4bce8dc2..1bf97cb17c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@types/node": "^22.0.0", "@types/react": "~18.3.1", diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 0104a7bee7..a93828282e 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -35,7 +35,6 @@ import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import Group from '@deepseek-ai/cordis-plugin-group' import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot' import { - addHarnessSourceSection, assertEntriesLoaded, composeEntries, healProfilesModuleFallback, @@ -65,6 +64,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' // Empty type imports carry the httpServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' +import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { REPO_ROOT, requireDist } from './support.ts' /** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */ @@ -459,6 +459,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We ctx.baseUrl = pathToFileURL(profileDir).href + '/' // This direct Loader harness supplies the same root-path capability as app-boot. ctx.provide('dshHomePath', dshHomePath) + // A host with no command line still provides one: the web bundle's startup + // row releases the rows waiting on it, and with no arguments each starts on + // the values this scaffold composed above. An exit request can only come + // from a rejected argument, which a fixed empty list has none of. + provideCmdline(ctx, { + args: [], + exit: (code) => { + throw new Error(`web e2e scaffold: the web app requested exit ${String(code)} with no arguments to reject`) + }, + }) await ctx.plugin(Loader) ctx.loader.builtins.include = Include // `cordis:group` beside it, exactly as `boot()` registers it: a group row is @@ -469,9 +479,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We // The shipped CLI deliberately has no dependency on this opt-in package. // Keep the Loader row real without broadening the product installation. if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis - if (surfaceContext) { - ctx.inject(['systemPrompt'], (promptCtx) => { addHarnessSourceSection(promptCtx, REPO_ROOT) }) - } await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(rootConfig).href, patches }, diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index b0acf074b4..ae5805dc8a 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -485,10 +485,13 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke child = spawn( process.execPath, [ - '--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port), + '--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', + // Launcher flags come first: the first token the launcher does not own + // starts the web app's own arguments. // Pin the in-browser picker: the shipped `-auto` row would resolve to // the native OS chooser on this bind, and no page can drive that. '--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + '--port', String(port), ], { cwd: sessionsDir, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 7ac7f488d9..4e3953bd1b 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -28,15 +28,15 @@ "commander": "^15.0.0" }, "peerDependencies": { - "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-include": "^1.0.4", + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 2ded85f25b..2ce1b72942 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/headless/README.md -README.md: f8b76b77f2beb22f501a49f0fc4cf5cd72223765 -README.zh.md: aae8ab5bea663b8909de942f72615f5ef9b16c84 +README.md: 45c87f0c85cbb68ad0366ea5f2c86e55fc307309 +README.zh.md: 22322692450fa85a87e9faf903abee0d38968f91 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index f8b76b77f2..45c87f0c85 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`). It mounts no Host, HTTP server, Web runtime, or browser plugin. +The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, shipped disabled until the startup row supplies the task). It mounts no Host, HTTP server, Web runtime, or browser plugin. -After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The launcher patches the task text in (`dsh run "task"`) and fails loud when the selected profile lacks this row. +After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the `headless-startup` row ([`src/startup.ts`](src/startup.ts)) reads it as the positional argument of `dsh --profile headless "task"` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), prints the app's `--help`, and rejects an invocation with no task instead of letting the runner's schema fail. ## Model Experience diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index aae8ab5bea..2232269245 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 +dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,在启动行供给任务之前以禁用状态交付)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 -Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。 +Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:`headless-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))把它读作 `dsh --profile headless "task"` 的位置参数,打印应用自己的 `--help`,并拒绝没有任务的调用,而不是让 runner 的 schema 失败。 ## 模型体验 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 8b147714be..6904931acc 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -1,7 +1,8 @@ # The dsh-headless bundle patch: one-shot task mode directly over dsh-base. -# It mounts no Host, HTTP server, Web runtime, or browser plugin. The launcher -# patches the runner's `task`; the direct driver creates an Agent through the -# core registry and prints the final durable assistant message. +# It mounts no Host, HTTP server, Web runtime, or browser plugin. The startup +# row owns the task positional (`dsh --profile headless "<task>"`) and this +# app's --help; the direct driver creates an Agent through the core registry +# and prints the final durable assistant message. - id: system-prompt config: @@ -22,5 +23,12 @@ - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' + - id: headless-startup + name: '@deepseek-ai/dsh-headless/startup' + + # Shipped off, not merely waiting: the runner's schema requires the task. + # The startup row enables it with the task after parsing this app's argv. - id: headless-runner name: '@deepseek-ai/dsh-headless' + inject: [headlessStartup] + disabled: true diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index c5090d4b93..5216aa3048 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -11,6 +11,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./startup": { + "types": "./lib/types/startup.d.ts", + "default": "./lib/startup.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -22,6 +26,7 @@ "files": [ "lib/index.js", "lib/invariant.js", + "lib/startup.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -32,15 +37,19 @@ } }, "dependencies": { + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0", + "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/dsh-web-app": "^0.0.1", "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -50,6 +59,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-web-app": "workspace:^", "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts new file mode 100644 index 0000000000..eb9907a6b7 --- /dev/null +++ b/packages/bundle/headless/src/startup.ts @@ -0,0 +1,70 @@ +/** + * The one-shot app's startup row: it owns the `dsh --profile headless` command + * line — the task text is this command's positional argument — and its + * `--help` text, then provides {@link HEADLESS_STARTUP_SERVICE} with the task + * the user asked for. The runner waits for it, so a missing task is a usage + * error printed by this command instead of a schema failure inside the runner. + * + * This app layers over the web app, and a composition has exactly one + * command-line owner: the bundle patch disables the web startup row, and this + * one also provides {@link WEB_STARTUP_SERVICE} so the web rows start on their + * composed (one-shot) values. + * @module @deepseek-ai/dsh-headless/startup + */ + +import { Command } from 'commander' +import type { Context } from 'cordis' +import type { EntryOptions } from '@cordisjs/plugin-loader' +import { overrideConfig, runStartup, type RowChange } from '@deepseek-ai/dsh-cmdline' +import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup' + +/** Stable Cordis plugin name. */ +export const name = 'headless-startup' + +/** Services required before the task can be resolved. */ +export const inject = ['cmdlineArgs'] + +/** The startup service the one-shot runner row injects. */ +export const HEADLESS_STARTUP_SERVICE = 'headlessStartup' + +/** The runner row this app configures. */ +const RUNNER_ROW_ID = 'headless-runner' + +/** + * This app's command: the task positional, its description, and its help text. + * @returns a fresh program, so one process can parse more than once (tests). + */ +function headlessCommand(): Command { + return new Command() + .name('dsh --profile headless') + .description('Answer one task, print the final assistant message, and exit.') + .helpOption('-h, --help', 'show this help') + .argument('[task...]', 'the task text; multiple words are joined by spaces') + .addHelpText('after', ` +Examples: + dsh --profile headless "run the tests" answer one task and exit +`) +} + +/** + * Turn the parsed command line into the runner row's task. + * @param program - the parsed headless command. + * @param rows - the waiting rows' composed options, in tree order. + * @returns row id → changes. + */ +function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): Map<string, RowChange> { + const task = program.args.join(' ') + if (task === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') + const runner = rows.find(row => row.id === RUNNER_ROW_ID) + if (runner === undefined) throw new Error(`headless-startup: the composition has no waiting "${RUNNER_ROW_ID}" row to run the task`) + return new Map([[RUNNER_ROW_ID, overrideConfig(runner, { task })]]) +} + +/** + * Resolve the task and start the rows waiting for it. + * @param ctx - plugin context carrying the command line and the Loader. + * @returns nothing once the runner is released, or once `--help` or a missing task requested exit. + */ +export function apply(ctx: Context): Promise<void> { + return runStartup(ctx, [HEADLESS_STARTUP_SERVICE, WEB_STARTUP_SERVICE], headlessCommand(), planHeadlessStartup) +} diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts new file mode 100644 index 0000000000..1b4d6f1430 --- /dev/null +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -0,0 +1,146 @@ +/** + * The one-shot app's startup row over a REAL Loader tree: the task + * positional reaches the runner row, a missing task is a usage error, and the + * web startup service this app absorbs releases its rows on the composed values. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import z from 'schemastery' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup' +import { afterEach, describe, expect, it } from 'vitest' +import { apply, HEADLESS_STARTUP_SERVICE } from '../src/startup.ts' + +/** What one boot of the fixture tree observed. */ +interface Observed { + started: Record<string, Record<string, unknown>> + exits: number[] + out: string + /** Patches the startup row handed the launcher for later compositions. */ + contributed: unknown[] +} + +const disposers: (() => Promise<void>)[] = [] + +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** + * Boot the real headless startup row over stand-ins for the runner row and one + * web row it absorbs. + * @param args - the invocation's inner arguments. + * @returns what the boot observed. + */ +async function bootStartup(args: string[], options: { withoutRunner?: boolean } = {}): Promise<Observed> { + const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-')) + const observed: Observed = { started: {}, exits: [], out: '', contributed: [] } + // The runner's real schema requires the task, which is exactly what makes a + // waiting-but-enabled row fail at fiber creation; the stand-in keeps that. + writeFileSync(join(dir, 'row.mjs'), ` +export const Config = globalThis.__headlessRunnerConfigSchema +export function apply(ctx, config) { globalThis.__headlessStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } +`) + writeFileSync(join(dir, 'plain-row.mjs'), ` +export function apply(ctx, config) { globalThis.__headlessStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } +`) + // The Loader imports a row through Node's own resolver, which cannot resolve + // this workspace's sources; the row delegates to the real plugin the test + // imported through the source-plane path mapping. + writeFileSync(join(dir, 'startup-row.mjs'), ` +export const name = 'headless-startup' +export const inject = ['cmdlineArgs'] +export const apply = ctx => globalThis.__headlessStartupApply(ctx) +`) + const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href + const plainRowUrl = pathToFileURL(join(dir, 'plain-row.mjs')).href + writeFileSync(join(dir, 'cordis.yml'), [ + // A composition that lost the runner still injects the startup service, so + // the startup row reaches its own row check rather than the generic one. + options.withoutRunner === true ? '- id: displaced-runner' : '- id: headless-runner', + ` name: ${rowUrl}`, + ` inject: [${HEADLESS_STARTUP_SERVICE}]`, + // Shipped off, like the bundle patch: the schema below requires the task, + // which only the startup row can supply. + ' disabled: true', + '- id: webserver', + ` name: ${plainRowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ' config:', + ' port: 0', + '- id: headless-startup', + ` name: ${pathToFileURL(join(dir, 'startup-row.mjs')).href}`, + '', + ].join('\n')) + const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + internals.stdout = observing + internals.stderr = observing + const globals = globalThis as unknown as { + __headlessStartupObserved: Observed + __headlessStartupApply: typeof apply + __headlessRunnerConfigSchema: unknown + } + globals.__headlessStartupObserved = observed + globals.__headlessStartupApply = apply + globals.__headlessRunnerConfigSchema = z.object({ task: z.string().required() }) + + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + provideCmdline(ctx, { + args, + exit: code => void observed.exits.push(code), + contribute: patches => void observed.contributed.push(...patches), + }) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) + await ctx.loader.await() + disposers.push(async () => { await ctx.fiber.dispose() }) + return observed +} + +describe('headless startup', () => { + it('joins the task positional and starts the runner with it', async () => { + const observed = await bootStartup(['run', 'the', 'tests']) + expect(observed.started['headless-runner']).toEqual({ task: 'run the tests' }) + expect(observed.exits).toEqual([]) + }) + + it('hands the task to the launcher as a patch, so a recomposition keeps it', async () => { + const observed = await bootStartup(['run', 'the', 'tests']) + expect(observed.contributed).toEqual([ + { id: 'headless-runner', disabled: false, config: { task: 'run the tests' } }, + ]) + }) + + it('starts the web rows it absorbed on the composed one-shot values', async () => { + const observed = await bootStartup(['task']) + expect(observed.started.webserver).toEqual({ port: 0 }) + }) + + it('rejects an invocation with no task instead of failing inside the runner schema', async () => { + const observed = await bootStartup([]) + expect(observed.out).toContain('a task is required') + expect(observed.started).toEqual({}) + expect(observed.exits).toEqual([1]) + }) + + it('fails the boot when the composition has no runner row to give the task to', async () => { + await expect(bootStartup(['task'], { withoutRunner: true })) + .rejects.toThrow('the composition has no waiting "headless-runner" row') + }) + + it('prints its own help and starts nothing', async () => { + const observed = await bootStartup(['--help']) + expect(observed.out).toContain('dsh --profile headless') + expect(observed.started).toEqual({}) + expect(observed.exits).toEqual([0]) + }) +}) diff --git a/packages/bundle/headless/tsconfig.json b/packages/bundle/headless/tsconfig.json index 9ae3212f6b..17d11ed3ff 100644 --- a/packages/bundle/headless/tsconfig.json +++ b/packages/bundle/headless/tsconfig.json @@ -31,6 +31,12 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../ui/cmdline" + }, + { + "path": "../web-app" } ] } diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 6594cdb6d1..e702feca98 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: d89ae4a7e28506166498caf0032f864bbb109cc5 -README.zh.md: 746ec2e8b6748a0d72f697d0aea5f3809e7106ee +README.md: 1b54e6d29ad49c62b7862bf7fffcd6d24831c643 +README.zh.md: 82b7c4c2574aa93697e8483c362cf4ec75630f34 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index d89ae4a7e2..1b54e6d29a 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, `--workspace-root`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model Experience diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 746ec2e8b6..82b7c4c257 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev`、`--workspace-root` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型体验 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 69335c90ac..7825d68383 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -3,9 +3,13 @@ # the profile's own cordis.patch.yml and any --patch overlays still to come. # # A patch replaces the targeted row's whole `config`, so each row below -# restates every key it owns. The `dsh web` launcher alias turns --host/--port/ -# --dev/--trusted-host into further patches over these rows -# (`--dev` inserts the dsh-client-hmr row). +# restates every key it owns. +# +# Rows this app configures from flags declare `inject: [webStartup]`: they wait +# until the web-startup row has parsed --host/--port/--dev/--workspace-root/ +# --trusted-host and provided that service with the resolved values. +# `dsh --profile web --help` therefore prints this app's own help and exits +# without ever binding a port. # ── surface-specific values the base deliberately omits ───────────────────── @@ -76,6 +80,11 @@ - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' + # Owns the web flag family and its --help; provides webStartup with the + # values this invocation resolved. Nothing waiting on it starts first. + - id: web-startup + name: '@deepseek-ai/dsh-web-app/startup' + # ── layer 2: transport/service ────────────────────────────────────────────── # Plain route-registration carrier; host and port arrive as `dsh web` @@ -83,6 +92,7 @@ # row below through the fallback seat. - id: webserver name: '@deepseek-ai/dsh-host-webserver' + inject: [webStartup] config: host: 127.0.0.1 port: 3080 @@ -96,12 +106,19 @@ # these host-owned shell variables. - id: web-runtime name: '@deepseek-ai/dsh-web-app' + inject: [webStartup] config: mode: production printUrl: true surfaceContext: true - # ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ── + # The client-plugin HMR receiver ships disabled; `--dev` enables it. + - id: client-hmr + name: '@deepseek-ai/dsh-client-hmr' + inject: [webStartup] + disabled: true + + # ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── # Dual-face: node half scans this very tree for dsh.client rows, composes # window.__DSH_BOOT__, serves /plugins/<id>/client.js; browser half is the @@ -114,6 +131,7 @@ # webserver under /api; browser half is the fetch/SSE client. - id: connection name: '@deepseek-ai/dsh-client-connection' + inject: [webStartup] - id: api-remotes name: '@deepseek-ai/dsh-api-remotes' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index f8cd162a11..e8240e1b63 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -11,6 +11,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./startup": { + "types": "./lib/types/startup.d.ts", + "default": "./lib/startup.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -22,6 +26,7 @@ "files": [ "lib/index.js", "lib/invariant.js", + "lib/startup.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -61,6 +66,7 @@ "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-frontend-static": "workspace:^", @@ -74,15 +80,18 @@ "@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": "^3.18.0", + "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" }, "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts new file mode 100644 index 0000000000..1b1969b0d6 --- /dev/null +++ b/packages/bundle/web-app/src/startup.ts @@ -0,0 +1,152 @@ +/** + * The web app's startup row: it owns the `dsh --profile web` flag family + * (`--host`, `--port`, `--dev`, `--workspace-root`, `--trusted-host`) and its + * `--help` text, turns those flags into changes on the rows that inject + * {@link WEB_STARTUP_SERVICE}, and then provides it. Until it does, no web row + * starts, so `dsh --profile web --help` prints this command's help and the + * server never binds. + * @module @deepseek-ai/dsh-web-app/startup + */ + +import { networkInterfaces } from 'node:os' +import { Command } from 'commander' +import type { Context } from 'cordis' +import type { EntryOptions } from '@cordisjs/plugin-loader' +import { overrideConfig, runStartup, type RowChange } from '@deepseek-ai/dsh-cmdline' + +/** Stable Cordis plugin name. */ +export const name = 'web-startup' + +/** Services required before the flags can be resolved. */ +export const inject = ['cmdlineArgs'] + +/** + * The startup service every flag-configured web row injects. The rows are + * listed in this bundle's `cordis.patch.yml`; a row this startup plans changes + * for without injecting the service fails loud. + */ +export const WEB_STARTUP_SERVICE = 'webStartup' + +/** The webserver schema's all-interfaces bind literal: only this bind derives LAN authorities. */ +const ALL_INTERFACES_HOST = '0.0.0.0' + +/** + * Non-internal IPv4 interface addresses of this machine — the IP-literal + * authorities an all-interfaces bind is reachable by on the LAN. + * @returns the addresses in interface order (possibly empty). + */ +function lanIPv4Addresses(): string[] { + return Object.values(networkInterfaces()).flat() + .filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal) + .map(iface => iface.address) +} + +/** + * One LAN-trust resolution for one invocation, sampled exactly once: the + * machine's LAN IP literals when the effective bind is all-interfaces, and the + * `trustedHosts` value built from them plus the explicit extras. The single + * sample is deliberate — display must advertise only addresses the fence was + * configured with, so the `web-runtime` row receives this same snapshot. + * Derived entries are port-less IP literals: DNS rebinding needs an + * attacker-controlled name, so an IP-literal Host is safe on any port, and the + * bound port may be OS-assigned, unknowable before the server binds. + * @param bindHost - the effective webserver bind host (the flag, else the composed row value). + * @param extra - `--trusted-host` values, in argv order. + * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty). + */ +export function resolveLanTrust( + bindHost: string | undefined, + extra: readonly string[], +): { lanAddresses: string[]; trustedHosts: string[] } { + const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [] + return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } +} + +/** The web flag family, as commander parsed it. */ +interface WebOptions { + host?: string + port?: string + dev?: boolean + workspaceRoot?: string + trustedHost?: string[] +} + +/** + * This app's command: its flags, its description, and its help text. + * @returns a fresh program, so one process can parse more than once (tests). + */ +function webCommand(): Command { + return new Command() + .name('dsh --profile web') + .description('Serve the DeepSeek Harness browser UI.') + .helpOption('-h, --help', 'show this help') + .option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine') + .option('--port <port>', 'listen port; pass 0 to let the OS pick a free one') + .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') + .option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI') + .option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') + .addHelpText('after', ` +Examples: + dsh web serve on the composed host and port + dsh web --port 8080 serve on another port + dsh web --host 0.0.0.0 reach it from another machine on the LAN + dsh web --dev mount the client-plugin HMR receiver +`) +} + +/** + * Turn the parsed flags into the changes each waiting row needs. + * @param program - the parsed web command. + * @param rows - the waiting rows' composed options, in tree order. + * @returns row id → changes; rows absent from the map start on their composed values. + */ +function planWebStartup(program: Command, rows: readonly EntryOptions[]): Map<string, RowChange> { + const options = program.opts<WebOptions>() + if (options.port !== undefined && !/^\d+$/.test(options.port)) { + program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) + } + const row = (id: string): EntryOptions => { + const found = rows.find(candidate => candidate.id === id) + if (found === undefined) throw new Error(`web-startup: the web composition has no waiting "${id}" row to configure`) + return found + } + const plan = new Map<string, RowChange>() + const webserver = row('webserver') + const composedHost = (webserver.config as { host?: string } | undefined)?.host + plan.set('webserver', overrideConfig(webserver, { + ...options.host !== undefined && { host: options.host }, + ...options.port !== undefined && { port: Number(options.port) }, + })) + if (options.workspaceRoot !== undefined) { + plan.set('api-gateway', overrideConfig(row('api-gateway'), { workspaceRoot: options.workspaceRoot })) + } + const { lanAddresses, trustedHosts } = resolveLanTrust(options.host ?? composedHost, options.trustedHost ?? []) + if (trustedHosts.length > 0) { + // Additive over the composed value: a cordis.patch.yml-configured fence + // authority must survive the derived LAN literals and the flag extras — + // dropping it silently would weaken security-relevant configuration. + const connection = row('connection') + const composedTrusted = (connection.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? [] + plan.set('connection', overrideConfig(connection, { trustedHosts: [...composedTrusted, ...trustedHosts] })) + } + // mode and lanAddresses are resolved on every boot, never pass-throughs of + // composed values: they describe this invocation, not the deployment. + plan.set('web-runtime', overrideConfig(row('web-runtime'), { + mode: options.dev === true ? 'development' : 'production', + lanAddresses, + })) + // The receiver ships disabled so `--dev` is a row toggle rather than a + // runtime insert (the Loader cannot resolve a row inserted from inside a + // mounting plugin). + if (options.dev === true) plan.set('client-hmr', { disabled: false }) + return plan +} + +/** + * Resolve the web flag family and start the rows waiting for it. + * @param ctx - plugin context carrying the command line and the Loader. + * @returns nothing once the waiting rows are released, or once `--help` requested exit. + */ +export function apply(ctx: Context): Promise<void> { + return runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup) +} diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts new file mode 100644 index 0000000000..c3cdfa08dc --- /dev/null +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -0,0 +1,163 @@ +/** + * The web app's startup row over a REAL Loader tree carrying this bundle's + * waiting row ids: flags reach the rows they configure, absent flags leave the + * composed values standing, `--dev` enables the shipped-disabled HMR receiver, + * and `--help` leaves the app unstarted. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apply, WEB_STARTUP_SERVICE } from '../src/startup.ts' + +vi.mock('node:os', async importOriginal => ({ + ...await importOriginal<typeof import('node:os')>(), + networkInterfaces: () => ({ + lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }], + en0: [{ family: 'IPv4', internal: false, address: '192.168.1.5' }], + }), +})) + +/** What one boot of the fixture tree observed. */ +interface Observed { + /** Config each waiting row started with, by row id; absent means it never started. */ + started: Record<string, Record<string, unknown>> + exits: number[] + out: string +} + +const disposers: (() => Promise<void>)[] = [] + +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** One stand-in for a row this bundle's patch makes wait for the web startup. */ +interface WaitingRow { + id: string + config?: Record<string, unknown> + disabled?: boolean +} + +/** The waiting rows this bundle's patch declares, with the composed values they ship. */ +const WAITING_ROWS: WaitingRow[] = [ + { id: 'webserver', config: { host: '127.0.0.1', port: 3080 } }, + { id: 'api-gateway', config: { provider: 'deepseek-official' } }, + { id: 'connection', config: { trustedHosts: ['configured.internal'] } }, + { id: 'web-runtime', config: { mode: 'production', printUrl: true } }, + { id: 'client-hmr', disabled: true }, +] + +/** + * Boot the real startup row over stand-ins for this bundle's waiting rows. + * @param args - the invocation's inner arguments. + * @returns what the boot observed. + */ +async function bootStartup(args: string[], rows: readonly WaitingRow[] = WAITING_ROWS): Promise<Observed> { + const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-')) + const observed: Observed = { started: {}, exits: [], out: '' } + writeFileSync(join(dir, 'row.mjs'), ` +export function apply(ctx, config) { globalThis.__webStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } +`) + // The Loader imports a row through Node's own resolver, which cannot resolve + // this workspace's sources; the row delegates to the real plugin the test + // imported through the source-plane path mapping. + writeFileSync(join(dir, 'startup-row.mjs'), ` +export const name = 'web-startup' +export const inject = ['cmdlineArgs'] +export const apply = ctx => globalThis.__webStartupApply(ctx) +`) + const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href + const lines = rows.flatMap(row => [ + `- id: ${row.id}`, + ` name: ${rowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ...row.disabled === true ? [' disabled: true'] : [], + ...row.config === undefined ? [] : [' config:', ...Object.entries(row.config).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`)], + ]) + lines.push('- id: web-startup', ` name: ${pathToFileURL(join(dir, 'startup-row.mjs')).href}`) + writeFileSync(join(dir, 'cordis.yml'), lines.join('\n') + '\n') + const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + internals.stdout = observing + internals.stderr = observing + const globals = globalThis as unknown as { __webStartupObserved: Observed; __webStartupApply: typeof apply } + globals.__webStartupObserved = observed + globals.__webStartupApply = apply + + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) + await ctx.loader.await() + disposers.push(async () => { await ctx.fiber.dispose() }) + return observed +} + +describe('web startup', () => { + it('applies each flag to the row that owns it and leaves the rest composed', async () => { + const observed = await bootStartup(['--port', '8080', '--workspace-root', '/w']) + expect(observed.started.webserver).toEqual({ host: '127.0.0.1', port: 8080 }) + expect(observed.started['api-gateway']).toEqual({ provider: 'deepseek-official', workspaceRoot: '/w' }) + expect(observed.started['web-runtime']).toEqual({ mode: 'production', printUrl: true, lanAddresses: [] }) + expect(observed.started['client-hmr']).toBeUndefined() + expect(observed.exits).toEqual([]) + }) + + it('starts every row on its composed values when the invocation carries no flags', async () => { + const observed = await bootStartup([]) + expect(observed.started.webserver).toEqual({ host: '127.0.0.1', port: 3080 }) + expect(observed.started.connection).toEqual({ trustedHosts: ['configured.internal'] }) + }) + + it('adds the LAN literals over the configured fence authorities for an all-interfaces bind', async () => { + const observed = await bootStartup(['--host', '0.0.0.0', '--trusted-host', 'lab.internal']) + expect(observed.started.webserver).toEqual({ host: '0.0.0.0', port: 3080 }) + expect(observed.started.connection).toEqual({ trustedHosts: ['configured.internal', '192.168.1.5', 'lab.internal'] }) + // Display gets the same single sample the fence was configured with. + expect(observed.started['web-runtime']).toEqual({ mode: 'production', printUrl: true, lanAddresses: ['192.168.1.5'] }) + }) + + it('enables the shipped-disabled HMR receiver for --dev', async () => { + const observed = await bootStartup(['--dev']) + expect(observed.started['client-hmr']).toEqual({}) + expect(observed.started['web-runtime']).toEqual({ mode: 'development', printUrl: true, lanAddresses: [] }) + }) + + it('prints its own help and starts nothing', async () => { + const observed = await bootStartup(['--help']) + expect(observed.out).toContain('dsh --profile web') + expect(observed.out).toContain('--trusted-host') + expect(observed.started).toEqual({}) + expect(observed.exits).toEqual([0]) + }) + + it('fails the boot when the composition lost a row this app configures', async () => { + // The bundle patch and this startup plugin must agree on the row set; a + // missing row would otherwise silently drop the flag that targets it. + const withoutWebserver = WAITING_ROWS.filter(row => row.id !== 'webserver') + await expect(bootStartup([], withoutWebserver)) + .rejects.toThrow('the web composition has no waiting "webserver" row') + }) + + it('derives the fence authorities alone when the composition configured none', async () => { + const withoutTrust = WAITING_ROWS.map(row => row.id === 'connection' ? { id: 'connection' } : row) + const observed = await bootStartup(['--host', '0.0.0.0'], withoutTrust) + expect(observed.started.connection).toEqual({ trustedHosts: ['192.168.1.5'] }) + }) + + it('rejects a non-numeric port before anything binds', async () => { + const observed = await bootStartup(['--port', 'abc']) + expect(observed.out).toContain('--port must be a number') + expect(observed.started).toEqual({}) + expect(observed.exits).toEqual([1]) + }) +}) diff --git a/packages/bundle/web-app/tests/trusted-hosts.spec.ts b/packages/bundle/web-app/tests/trusted-hosts.spec.ts new file mode 100644 index 0000000000..110aaeae61 --- /dev/null +++ b/packages/bundle/web-app/tests/trusted-hosts.spec.ts @@ -0,0 +1,33 @@ +/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */ + +import { describe, expect, it, vi } from 'vitest' +import { resolveLanTrust } from '../src/startup.ts' + +vi.mock('node:os', () => ({ + networkInterfaces: () => ({ + lo0: [ + { family: 'IPv4', internal: true, address: '127.0.0.1' }, + ], + en0: [ + { family: 'IPv6', internal: false, address: 'fe80::1' }, + { family: 'IPv4', internal: false, address: '192.168.1.5' }, + ], + en1: [ + { family: 'IPv4', internal: false, address: '10.0.0.7' }, + ], + utun0: undefined, + }), +})) + +describe('resolveLanTrust', () => { + it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => { + const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080']) + expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7']) + expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) + }) + + it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => { + expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] }) + expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) + }) +}) diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index 6aadb534cb..b15ebb1664 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -14,6 +14,12 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../ui/cmdline" + }, { "path": "../../host/frontend-static" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d890741ac..a05f336f3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,6 +356,9 @@ importers: '@deepseek-ai/dsh-client-web-react': specifier: workspace:^ version: link:../../packages/client/web-react + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../packages/boot/cmdline '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/bash/pwsh-local @@ -1181,18 +1184,18 @@ importers: specifier: ^15.0.0 version: 15.0.0 devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bundle/base: dependencies: @@ -1443,12 +1446,18 @@ importers: packages/bundle/headless: dependencies: + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../boot/cmdline '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + commander: + specifier: ^15.0.0 + version: 15.0.0 devDependencies: '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 @@ -1471,6 +1480,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-web-app': + specifier: workspace:^ + version: link:../web-app packages/bundle/web-app: dependencies: @@ -1558,6 +1570,9 @@ importers: '@deepseek-ai/dsh-client-ui-workspace': specifier: workspace:^ version: link:../../client/ui-workspace + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../boot/cmdline '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker @@ -1600,10 +1615,16 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + commander: + specifier: ^15.0.0 + version: 15.0.0 devDependencies: '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-bash-env': specifier: workspace:^ version: link:../../bash/bash-env From 37cbd155f5386286fb9ae7b93798de639b4f54ec Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 20:52:26 +0800 Subject: [PATCH 156/229] refactor(cli)!: the launcher parses only its own flags Launcher flags come first and end at the first token dsh does not recognize; everything after reaches the booted app verbatim, so dsh --profile tui --resume <id> works with no launcher change and dsh --profile web --help prints the web app's help. A bare dsh -h, which has no app to hand the flag to, still prints the launcher's own. src/web.ts is deleted: the Web flag family, its LAN-trust sampling, and the one-shot task positional now live in their bundles, and runProfile no longer knows any row id. What the startup row decides comes back as a launcher-owned patch layer above every layer a user can edit, so a live config edit recomposes the tree without resetting a served port. dsh web and dsh --profile web finally boot through one path, which also gives --profile web the harness-source prompt section that only the alias used to add. --- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 20 +- apps/cli/README.zh.md | 22 ++- apps/cli/package.json | 1 + apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 34 ++-- apps/cli/reference/README.zh.md | 34 ++-- apps/cli/src/args.ts | 228 +++++++++-------------- apps/cli/src/bin.ts | 20 +- apps/cli/src/profile-boot.ts | 148 +++++++-------- apps/cli/src/web.ts | 144 --------------- apps/cli/tests/args.spec.ts | 63 ++++--- apps/cli/tests/built-bin.e2e.ts | 267 +++++++++++++++++++++++---- apps/cli/tests/trusted-hosts.spec.ts | 45 ----- apps/cli/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 16 files changed, 518 insertions(+), 522 deletions(-) delete mode 100644 apps/cli/src/web.ts delete mode 100644 apps/cli/tests/trusted-hosts.spec.ts diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 101332555d..b4c933291e 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: dd29f7fc03a783079ea3194de99589c1f545be5b -README.zh.md: 60e7aa1ec1ea2fad7e3f3d97a0f6bf42355adffc +README.md: 86d890ebec7121a9f8431f52789b8346ba59deb2 +README.zh.md: 80b9a6d56bdb49f72d25f7485662a6814f5184a3 diff --git a/apps/cli/README.md b/apps/cli/README.md index dd29f7fc03..86d890ebec 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -9,15 +9,27 @@ The `dsh` command is the product launcher for profiles: ordered stacks of plugin | Command | Purpose | |---|---| | `dsh --profile <name>` | Boot the named profile under `$DSH_HOME/profiles/<name>`. | -| `dsh run [--profile <name>] [--patch <path>...] "task"` | Run one fresh persisted session directly over core, print the final answer, and exit; the profile defaults to `headless` and mounts no Web server. | -| `dsh web` | Alias of `--profile web` with the Web flag family (`--host`, `--port`, `--dev`, ...). | +| `dsh --profile headless "task"` | Run one fresh persisted session, print the final answer, and exit. | +| `dsh web` | Alias of `--profile web`. | | `dsh plugin --profile <name> <pnpm args>` | Manage a profile's plugins by forwarding to pnpm in the profile directory. | -The invoking directory is the default workspace root. `dsh run` requires non-blank task text and the selected profile must mount the `headless-runner` row; `--profile` preserves custom one-shot profiles. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. +The invoking directory is the default workspace root. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. + +## App arguments + +The launcher parses only its own flags and hands everything after them to the booted profile, where that app's own startup row parses them ([`dsh-cmdline`](../../packages/ui/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments: + +```sh +dsh --profile web --port 8080 # --port belongs to the web app +dsh --profile tui --resume <id> # --resume belongs to the terminal app +dsh --profile headless "run the tests" +dsh --profile web --help # the web app's flags, not the launcher's +dsh --help # the launcher's own help +``` ## Profiles -A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays, then flag patches. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. +A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and the source launcher. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 60e7aa1ec1..80b9a6d56b 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -9,18 +9,30 @@ | 命令 | 用途 | |---|---| | `dsh --profile <name>` | 启动位于 `$DSH_HOME/profiles/<name>` 的指定 profile。 | -| `dsh run [--profile <name>] [--patch <path>...] "task"` | 直接在 core 上运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`,且不挂载 Web server。 | -| `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 | +| `dsh --profile headless "task"` | 运行一个新的持久化会话,打印最终答案并退出。 | +| `dsh web` | `--profile web` 的别名。 | | `dsh plugin --profile <name> <pnpm args>` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 | -调用目录是默认 workspace 根目录。`dsh run` 要求任务文本非空白,且所选 profile 必须挂载 `headless-runner` 行;`--profile` 保留对自定义一次性 profile 的支持。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 +调用目录是默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 + +## 应用参数 + +启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../packages/ui/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点: + +```sh +dsh --profile web --port 8080 # --port belongs to the web app +dsh --profile tui --resume <id> # --resume belongs to the terminal app +dsh --profile headless "run the tests" +dsh --profile web --help # the web app's flags, not the launcher's +dsh --help # the launcher's own help +``` ## Profile -profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay,最后是 flag patch。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 +profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 [CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码启动器。 ## 开发 -生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析约定。 +生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约。 diff --git a/apps/cli/package.json b/apps/cli/package.json index a2695f59c9..4442006b0a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -27,6 +27,7 @@ "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index ba0fd57bc6..9916a9eeb3 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 756fed1f1802600e82948ce8ca808706b2299660 -README.zh.md: edd20c7fc3a3103097aa5e3949418e373172cadb +README.md: 13c0d000eec045cc34f2b7eb5fe5ba9ac9ed557e +README.zh.md: 5392db3220013a50040bf59f212040e8d0291037 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 756fed1f18..13c0d000ee 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -2,17 +2,32 @@ English | [中文](README.zh.md) -This reference defines the profile, one-shot run, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. +This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. ## Profile boot -`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), each `--patch <path>` overlay in argv order, and launcher flag patches. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. +`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). -The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). On load, the exact installation-owned headless tuple (base + web-app + headless) normalizes to the shipped template; extra, missing, or reordered bundle lists are user-owned and remain untouched. Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`. +The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`. -Profile boot accepts no positional task. A profile that mounts the one-shot runner row (`headless-runner`) therefore fails loud with the canonical `dsh run --profile <name> "<task>"` command instead of reaching the row's raw required-field error. +### App arguments + +The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where that app's own startup row parses it ([`dsh-cmdline`](../../../packages/ui/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary. + +A composition mounts once. A Loader row that injects `cmdlineArgs` parses this app's arguments and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the startup service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port. + +Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. A profile with no active row injecting `cmdlineArgs` accepts no app arguments; it rejects them before mounting any row instead of silently ignoring them. + +The shipped apps own these command lines: + +| Profile | Arguments | +|---|---| +| `web` | `--host`, `--port`, `--dev`, `--workspace-root`, repeatable `--trusted-host` | +| `headless` | the task text, as the positional argument | + +A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. Inspect the composed tree without booting it: @@ -21,13 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. - -## One-shot run - -`dsh run [--profile <name>] [--patch <path>...] <task...>` joins the task arguments with spaces, rejects a missing or blank task, and defaults `--profile` to `headless`. Repeatable `--patch` overlays occupy the same layer position as profile-boot overlays. A custom selected profile must mount `headless-runner`; otherwise launch fails before boot with a diagnostic naming that missing row. - -The launcher patches the task text into the runner row. After Loader settlement, the runner reads the shared `ctx.agentDefaultModel` default, creates one fresh persisted Agent through `ctx.agents`, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. +`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs an app's startup row, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. ## Plugin management @@ -43,12 +52,13 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, which owns them in its bundle's startup row. `--host`, `--port`, and `--workspace-root` override the composed values of the rows that carry them, repeatable `--trusted-host` adds authorities over the composed fence configuration, and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web dsh web --patch ./extra.cordis.yml dsh web --dump-config +dsh web --help ``` The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index edd20c7fc3..5392db3220 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -2,17 +2,32 @@ [English](README.md) | 中文 -本参考定义 profile、一次性运行、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 +本参考定义 profile、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 ## Profile 启动 -`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、按 argv 顺序的各个 `--patch <path>` overlay,以及启动器 flag patch。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 +`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、以及按 argv 顺序的各个 `--patch <path>` overlay。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 组合包名称先从 dsh 安装解析,再从 profile 目录解析。因此内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`)总是来自与正在运行的 `dsh` 相同的安装;树外组合包来自 profile 由 pnpm 管理的 `node_modules`。任何 patch 行中的裸插件 `name` 通过 profile 目录的 Node 父目录逐级查找解析,该查找可达到持续维护的安装后备目录 `$DSH_HOME/profiles/node_modules`(安装的应用和组合包所依赖的每个包对应一个符号链接,每次启动时修复)。 -`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。加载时,与安装所管理的 headless 元组(base + web-app + headless)完全一致的列表会规范化为随附模板;包含额外项、缺少项或调整过顺序的组合包列表由用户拥有,保持不变。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。 +`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。 -Profile 启动不接受位置参数任务。因此,挂载了一次性运行器行(`headless-runner`)的 profile 会显式报错,并提示规范命令 `dsh run --profile <name> "<task>"`,而不会触发该行原始的必填字段错误。 +### 应用参数 + +启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../../packages/ui/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。 + +一套组合只挂载一次。注入 `cmdlineArgs` 的 Loader 行解析本应用的参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖启动服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。 + +启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。若 profile 中没有注入 `cmdlineArgs` 的活跃行,该 profile 不接受应用参数;启动器会在挂载任何行之前拒绝这些参数,而不是静默忽略。 + +随附的各应用持有这些命令行: + +| Profile | 参数 | +|---|---| +| `web` | `--host`、`--port`、`--dev`、`--workspace-root`、可重复的 `--trusted-host` | +| `headless` | 任务文本,作为位置参数 | + +一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 可在不启动的情况下检查组合出的配置树: @@ -21,13 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。 - -## 一次性运行 - -`dsh run [--profile <name>] [--patch <path>...] <task...>` 会用空格拼接任务参数,拒绝缺失或空白任务,并让 `--profile` 默认为 `headless`。可重复使用的 `--patch` overlay 与 profile 启动的 overlay 位于同一层。所选的自定义 profile 必须挂载 `headless-runner`;否则启动器会在启动前失败,并在诊断中指明缺少该行。 - -启动器把任务文本 patch 进运行器行。Loader 结算后,运行器读取共享的 `ctx.agentDefaultModel` 默认值,通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 +`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 从不运行应用的启动行,因此它展示的是任何应用参数被解析之前的组合配置树,并拒绝携带应用参数的调用。 ## 插件管理 @@ -43,12 +52,13 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由该应用在其组合包的启动行中持有。`--host`、`--port` 和 `--workspace-root` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 在组合出的围栏配置之上追加 authority,`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web dsh web --patch ./extra.cordis.yml dsh web --dump-config +dsh web --help ``` 生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 97f5222398..43d68a2c7c 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,31 +1,30 @@ /** - * Commander adapter for the `dsh` command-line entry. The default command - * boots a named profile (`--profile <name>`), optionally with extra `--patch` - * overlays. `run` owns one-shot task execution, defaulting to the headless - * profile; `web` is a hardcoded alias for `--profile web` that adds the Web - * flag family; `plugin` manages a profile's plugin dependencies by forwarding - * to pnpm. Commander owns help, version, and parse errors. + * Commander adapter for the `dsh` command line. + * + * The launcher parses only what it owns — which profile to boot, which extra + * patch overlays to apply, and the config dumps — and hands **everything after + * its own flags** to the booted tree verbatim, where the booted app's startup row + * parses its own flag family and prints its own `--help` (see + * `@deepseek-ai/dsh-cmdline`). Launcher flags therefore come first: the first + * token this parser does not recognize starts the inner arguments, so + * `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`, + * and `dsh --profile web -h` prints the web app's help, not this one's. + * + * `web` is a hardcoded alias for `--profile web`; `plugin` manages a profile's + * plugin dependencies by forwarding to pnpm. * @module @deepseek-ai/dsh/args */ import { Command, CommanderError } from 'commander' -/** Boot a named profile. */ +/** Boot a named profile and hand it the invocation's inner arguments. */ interface ProfileInvocation { mode: 'profile' profile: string /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ patches: string[] -} - -/** Run one task through a profile mounting the headless runner. */ -interface RunInvocation { - mode: 'run' - profile: string - /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ - patches: string[] - /** Non-blank task text joined from the variadic positional arguments. */ - task: string + /** Everything after the launcher's own flags, verbatim, for the booted app's startup row. */ + args: string[] } /** Print a composed profile tree and exit without booting. */ @@ -37,21 +36,6 @@ interface DumpConfigInvocation { patches: string[] } -/** - * Browser UI: `dsh web` (alias of `--profile web`). Host and port remain - * unvalidated pass-throughs to the webserver schema; absent values leave the - * shipped web bundle values intact. - */ -interface WebInvocation { - mode: 'web' - patches: string[] - host?: string - port?: number - dev: boolean - /** Extra authorities for the /api browser-trust fence. */ - trustedHosts?: string[] -} - /** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */ interface PluginInvocation { mode: 'plugin' @@ -61,31 +45,63 @@ interface PluginInvocation { } /** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = ProfileInvocation | RunInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation +export type DshInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation -/** Raw web-subcommand options straight from Commander. */ -interface WebOptions { +/** Launcher flags shared by the default command and the `web` alias. */ +interface BootOptions { patch?: string[] - host?: string - port?: string - dev?: boolean - trustedHost?: string[] dumpConfig?: boolean dumpDefaultConfig?: boolean } -/** Raw run-subcommand options straight from Commander. */ -interface RunOptions { - profile: string - patch?: string[] -} - /** * Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never - * variadic — a variadic `--patch` would swallow a following positional task. + * variadic — a variadic `--patch` would swallow the inner arguments. */ const collect = (value: string, previous: string[] = []): string[] => [...previous, value] +/** The launcher's own help text; each app prints its own. */ +const HELP_EXAMPLES = ` +Examples: + dsh --profile web boot the web profile (same as: dsh web) + dsh --profile headless "run the tests" answer one task, print the result, and exit + dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay + dsh --profile tui --resume <session> arguments after the launcher flags reach the app + dsh --profile web --help the web app's own flags and help + dsh plugin --profile tui add <package> install a plugin into the tui profile +` + +/** + * Resolve a boot or dump invocation from the launcher flags and the leftover + * inner arguments. + * @param program - the command whose options were parsed (the root, or the `web` alias). + * @param profile - the profile these flags boot. + * @param options - the launcher flags commander collected. + * @param args - the leftover arguments, in argv order. + * @returns the resolved invocation. + */ +function resolveBoot(program: Command, profile: string, options: BootOptions, args: string[]): DshInvocation { + const patches = options.patch ?? [] + if (patches.includes('')) program.error('error: --patch needs a path') + if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) { + return { mode: 'profile', profile, patches, args } + } + if (options.dumpConfig === true && options.dumpDefaultConfig === true) { + program.error('error: --dump-config and --dump-default-config are mutually exclusive') + } + // The dump is boot-free: it never runs the app's startup row, so it cannot + // show what that app's flags would decide, and printing a tree that differs + // from the same invocation's boot would mislead. + if (args.length > 0) { + program.error(`error: config dumps take no app arguments, got ${args.map(argument => JSON.stringify(argument)).join(' ')}`) + } + const defaultOnly = options.dumpDefaultConfig === true + if (defaultOnly && patches.length > 0) { + program.error('error: --dump-default-config prints the bundle layers and takes no --patch') + } + return { mode: 'dump-config', profile, defaultOnly, patches } +} + /** * Resolve argv into one invocation, or print and exit for help, version, or an * error. @@ -95,121 +111,61 @@ const collect = (value: string, previous: string[] = []): string[] => [...previo */ export function parseDshArgs(argv: readonly string[], version: string): DshInvocation { let resolved: DshInvocation | undefined - const program = new Command() + // Annotated, not inferred: the actions below call back into `program`, and an + // inferred type would be circular through its own chain. + const program: Command = new Command() + program .name('dsh') .version(version, '-V, --version', 'output the version number') .description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.') - .addHelpText('after', ` -Examples: - dsh --profile web boot the web profile (same as: dsh web) - dsh run "run the tests" answer one task, print the result, and exit - dsh run --profile custom "run the tests" run one task through a custom one-shot profile - dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay - dsh plugin --profile tui add <package> install a plugin into the tui profile - dsh web --port 8080 the web alias with its flag family -`) + .addHelpText('after', HELP_EXAMPLES) .exitOverride() + // The launcher's flags come first and end at the first token it does not + // know; everything from there on belongs to the booted app, including + // its -h. `dsh -h` with no profile still prints this help, below. + .helpOption(false) + .allowUnknownOption() + .passThroughOptions() .enablePositionalOptions() + .argument('[args...]', 'arguments for the booted profile\'s app (see: dsh --profile <name> --help)') .option('--profile <name>', 'the profile under $DSH_HOME/profiles to boot') .option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) .option('--dump-config', 'print the composed profile tree and exit') .option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit') - .action((options: { - profile?: string - patch?: string[] - dumpConfig?: boolean - dumpDefaultConfig?: boolean - }) => { - const profile = options.profile ?? program.error('error: --profile <name> is required') - if (profile === '') program.error('error: --profile needs a name') - const patches = options.patch ?? [] - if (patches.includes('')) program.error('error: --patch needs a path') - if (options.dumpConfig === true || options.dumpDefaultConfig === true) { - if (options.dumpConfig === true && options.dumpDefaultConfig === true) { - program.error('error: --dump-config and --dump-default-config are mutually exclusive') - } - const defaultOnly = options.dumpDefaultConfig === true - if (defaultOnly && patches.length > 0) { - program.error('error: --dump-default-config prints the bundle layers and takes no --patch') - } - resolved = { mode: 'dump-config', profile, defaultOnly, patches } - return + .action((args: string[], options: BootOptions & { profile?: string }) => { + // With the app owning -h, the launcher's own help is what a bare + // `dsh -h` (no profile to hand it to) must print. + if (options.profile === undefined) { + if (args.some(argument => argument === '-h' || argument === '--help')) program.help() + program.error('error: --profile <name> is required') } - resolved = { mode: 'profile', profile, patches } + const profile = options.profile + if (profile === '') program.error('error: --profile needs a name') + resolved = resolveBoot(program, profile, options, args) }) /** Reject parent options supplied before a subcommand. */ const rejectParentOptions = (command: string): void => { - const parent = program.opts<{ - profile?: string - patch?: string[] - dumpConfig?: boolean - dumpDefaultConfig?: boolean - }>() + const parent = program.opts<BootOptions & { profile?: string }>() if (parent.profile !== undefined || parent.patch !== undefined || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) { program.error(`error: ${command} takes none of parent --profile, --patch, --dump-config, or --dump-default-config`) } } - const run = program.command('run').description('run one task through a profile mounting the headless runner') - run - .option('--profile <name>', 'one-shot profile under $DSH_HOME/profiles', 'headless') - .option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) - .argument('<task...>', 'task text') - .action((task: string[], options: RunOptions) => { - rejectParentOptions('run') - const profile = options.profile - if (profile === '') program.error('error: --profile needs a name') - const patches = options.patch ?? [] - if (patches.includes('')) program.error('error: --patch needs a path') - const joined = task.join(' ') - if (joined.trim() === '') program.error('error: run needs a non-blank task') - resolved = { mode: 'run', profile, patches, task: joined } - }) - - const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port') + const web = program.command('web').description('boot the web profile (alias of --profile web); the web app\'s own flags follow') web + .helpOption(false) + .allowUnknownOption() + .passThroughOptions() + .enablePositionalOptions() + .argument('[args...]', 'arguments for the web app (see: dsh web --help)') .option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) - .option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine') - .option('--port <port>', 'listen port; pass 0 to let the OS pick a free one') - .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') - .option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit') .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit') - .action((options: WebOptions) => { + .action((args: string[], options: BootOptions) => { rejectParentOptions('web') - const patches = options.patch ?? [] - if (patches.includes('')) program.error('error: --patch needs a path') - if (options.dumpConfig === true || options.dumpDefaultConfig === true) { - if (options.dumpConfig === true && options.dumpDefaultConfig === true) { - program.error('error: --dump-config and --dump-default-config are mutually exclusive') - } - const defaultOnly = options.dumpDefaultConfig === true - if (defaultOnly && patches.length > 0) { - program.error('error: --dump-default-config prints the bundle layers and takes no --patch') - } - // The dump is boot-free and does not derive flag patches; silently - // dropping them would print a tree that differs from the same - // invocation's boot. - if (options.host !== undefined || options.port !== undefined || options.dev === true - || options.trustedHost !== undefined) { - program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)') - } - resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches } - return - } - if (options.port !== undefined && !/^\d+$/.test(options.port)) { - program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) - } - resolved = { - mode: 'web', - patches, - ...options.host !== undefined && { host: options.host }, - ...options.port !== undefined && { port: Number(options.port) }, - dev: options.dev === true, - ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, - } + resolved = resolveBoot(web, 'web', options, args) }) const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory') diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index b332a64615..d0e8e9d138 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot' +import { loadEnv } from '@deepseek-ai/dsh-app-boot' import { parseDshArgs } from './args.ts' // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit @@ -24,33 +24,19 @@ function readVersion(): string { return typeof manifest.version === 'string' ? manifest.version : '0.0.0' } +loadEnv('dsh') const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'profile': { const { runProfile } = await import('./profile-boot.ts') await runProfile({ - environment: loadLayeredEnv('dsh'), profile: invocation.profile, patchFiles: invocation.patches, + args: invocation.args, }) break } - case 'run': { - const { runProfile } = await import('./profile-boot.ts') - await runProfile({ - environment: loadLayeredEnv('dsh'), - profile: invocation.profile, - patchFiles: invocation.patches, - task: invocation.task, - }) - break - } - case 'web': { - const { runWeb } = await import('./web.ts') - await runWeb(invocation, loadLayeredEnv('dsh')) - break - } case 'plugin': { const { runPlugin } = await import('./plugin.ts') process.exit(runPlugin(invocation.profile, invocation.args)) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 8c37af124a..5acebe9b44 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -1,9 +1,13 @@ /** * Shared profile boot for every `dsh` surface: resolve the profile, stack its - * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's own - * `cordis.patch.yml`, `--patch` overlays, flag-derived patches, the telemetry - * switch), mount the tree over the profile's empty root config, keep the - * profile patch layer live, and wire fail-loud plus bounded shutdown. + * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's + * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the + * tree over the profile's empty root config, keep the profile patch layer + * live, and wire fail-loud plus bounded shutdown. + * + * App flags are not the launcher's business: the invocation's inner arguments + * are provided to the tree through `ctx.cmdlineArgs`, and the booted app's + * startup row parses them and configures its own rows. * @module @deepseek-ai/dsh/profile-boot */ @@ -12,7 +16,7 @@ import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { FiberState, type Context } from '@deepseek-ai/cordis' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { dshHomePath } from '@deepseek-ai/dsh-paths' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { boot, composeEntries, @@ -25,7 +29,7 @@ import { watchUserPatches, type Profile, } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' /** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url)) @@ -33,6 +37,7 @@ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', im /** Harness-home directory holding locally authored agent presets. */ const USER_PRESET_DIR = '.agent-presets' import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' +import { hasCmdlineConsumer, provideCmdline } from '@deepseek-ai/dsh-cmdline' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' import { resolveWindowsShellLayer } from './windows-shell.ts' @@ -55,7 +60,7 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */ const TELEMETRY_ROW_ID = 'telemetry-otel' -/** The one-shot runner row a `dsh run` task requires and configures. */ +/** The one-shot runner row: its presence means this composition exits by itself. */ const HEADLESS_ROW_ID = 'headless-runner' /** The empty root entry list every profile tree patches over. */ @@ -104,9 +109,6 @@ export function prepareProfile(name: string, userLayer = true): Profile { return profile } -/** Read-only row index of a profile composition before launcher flag patches. */ -export type ProfileRows = ReadonlyMap<string, { name?: string; config?: unknown }> - /** One profile's patch layers (application order) and the row index of its pre-flag composition. */ interface ComposedProfile { profile: Profile @@ -116,14 +118,13 @@ interface ComposedProfile { windowsShellPatches: PatchOptions[] /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */ homePatches: PatchOptions[] - /** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */ - overlayAndFlags: PatchOptions[] + /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */ + overlays: PatchOptions[] /** - * id → row of the pre-flag composition (bundles + user layers + overlays), - * for flag merges and row checks. Flag patches must not insert rows the - * launcher consults here (they only override values and insert dev glue). + * id → row of the composed tree (bundles + user layers + overlays), for the + * launcher's own row checks. */ - rows: ProfileRows + rows: ReadonlyMap<string, EntryOptions> } /** The full patch stack of one composed profile, in application order. */ @@ -133,7 +134,7 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { ...composed.windowsShellPatches, ...composed.profile.patches, ...composed.homePatches, - ...composed.overlayAndFlags, + ...composed.overlays, ] } @@ -143,36 +144,28 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { * is Windows), the profile's user layer, the home-level user layer * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to * every profile, so it outranks the per-profile layer), `--patch` overlays, - * then flag patches derived from the composed rows, then the telemetry - * switch. + * then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. - * @param deriveFlagPatches - launcher hook turning composed rows into flag patches. * @returns the profile, its patch layers, and the composed row index. */ function composeProfile( name: string, patchFiles: readonly string[], - deriveFlagPatches: (rows: ComposedProfile['rows']) => PatchOptions[] = () => [], ): ComposedProfile { const profile = prepareProfile(name) const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? [] - const rows = new Map<string, { name?: string; config?: unknown }>() + const rows = new Map<string, EntryOptions>() for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) { if (typeof row.id === 'string') rows.set(row.id, row) } - const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)] - // The agent-preset roots are an assembly fact of every dsh launcher, not a - // patch author's choice: the shipped set sits beside this app's config and - // the user's own under the Harness home. Resolved per boot ($DSH_HOME may - // differ per run) and only patched when the composed tree actually mounts - // the roster — a one-shot `dsh run` composes agents from the same roster - // `dsh web` offers. + const composedOverlays = [...overlays] + // Preset roots belong to every dsh composition that mounts the roster. if (rows.has('agent-presets')) { - overlayAndFlags.push({ + composedOverlays.push({ id: 'agent-presets', config: { ...(rows.get('agent-presets')?.config ?? {}) as Record<string, unknown>, @@ -184,58 +177,55 @@ function composeProfile( }) } const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) - if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch) - return { profile, bundlePatches, windowsShellPatches, homePatches, overlayAndFlags, rows } + if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch) + return { profile, bundlePatches, windowsShellPatches, homePatches, overlays: composedOverlays, rows } } /** Options for {@link runProfile}. */ export interface RunProfileOptions { + /** This run's frozen environment snapshot, provided before any entry mounts. */ + environment: EnvironmentSnapshot /** The profile name to boot. */ profile: string /** `--patch` overlay paths, in argv order. */ patchFiles: readonly string[] - /** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */ - deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[] - /** `dsh run` task text; requires the composition to mount the headless runner row. */ - task?: string - /** Surface setup registered after Loader installation and before any config-tree entry mounts. */ - prepare?: (ctx: Context, rows: ProfileRows) => Promise<void> | void - /** This run's frozen environment snapshot, provided to the tree before any entry mounts. */ - environment: EnvironmentSnapshot -} - -/** Re-throw setup failures unless this invocation's signal already owns shutdown. */ -function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void { - if (!signal.aborted) throw error + /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */ + args: readonly string[] + /** Host setup registered after Loader installation and before any config-tree entry mounts. */ + prepare?: (ctx: Context) => Promise<void> | void } /** * Boot one profile invocation end to end and leave process lifetime to the - * mounted plugins (or to the one-shot runner when `task` is present). - * @param options - profile name, overlays, flag patches, and the optional task. + * mounted plugins (or to a one-shot runner the composition mounts). + * @param options - environment snapshot, profile name, overlays, and the booted app's own arguments. * @returns the settled root context and the shutdown controller. */ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> { - const composed = composeProfile(options.profile, options.patchFiles, options.deriveFlagPatches) - if (options.task !== undefined) { - if (!composed.rows.has(HEADLESS_ROW_ID)) { - throw new Error( - `dsh: profile ${JSON.stringify(options.profile)} takes no task — its composition mounts no "${HEADLESS_ROW_ID}" row ` - + '(the headless profile does)', - ) - } - composed.overlayAndFlags.push({ id: HEADLESS_ROW_ID, config: { task: options.task } }) - } else if (composed.rows.has(HEADLESS_ROW_ID)) { - // The inverse misuse: a one-shot composition booted without its task - // would otherwise die in the runner row's schema with a raw "required" - // error naming no fix. + const composed = composeProfile(options.profile, options.patchFiles) + if (!hasCmdlineConsumer([...composed.rows.values()]) && options.args.length > 0) { throw new Error( - `dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: ` - + `dsh run --profile ${options.profile} "<task>"`, + `${NAME}: profile ${JSON.stringify(options.profile)} takes no app arguments because no active row injects cmdlineArgs; ` + + `got ${options.args.map(argument => JSON.stringify(argument)).join(' ')}`, ) } + // A one-shot composition ends by itself, which changes what a signal means + // and makes watching the user's patch layer pointless. + const headlessRow = composed.rows.get(HEADLESS_ROW_ID) + const oneShot = headlessRow !== undefined && headlessRow.disabled !== true const app: { current?: Context } = {} + // Readiness for rows that publish it (the web URL line): a row can activate + // before concurrently mounted siblings finish or fail. + let bootSettled: () => void = () => {} + let bootFailed: (reason: unknown) => void = () => {} + const ready = new Promise<void>((resolve, reject) => { + bootSettled = resolve + bootFailed = reject + }) + // Nothing awaits `ready` on a composition that publishes no readiness, and + // an unobserved rejection must not take the process down on its own. + ready.catch(() => {}) const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) const signalShutdown = new AbortController() const interrupt = (code: number): void => { @@ -243,9 +233,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con shutdown.interrupt(code) } // Signals own teardown throughout the startup window, not only after boot() - // settles: an inserted entry point can publish readiness before sibling rows + // settles: an inserted startup row can publish readiness before sibling rows // finish mounting. - process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) }) + process.on('SIGTERM', () => { interrupt(oneShot ? 143 : 0) }) process.on('SIGINT', () => { interrupt(130) }) installFailLoud(NAME, process, async () => { await app.current?.fiber.dispose() @@ -253,7 +243,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME) // Recomposition for the live user layers: bundle layers below, overlays - // and flag patches above, so a user edit can never displace them. BOTH + // above, so a user edit can never displace them. What an app's startup row + // resolved is not in here at all — it lives in that row's own service, which + // survives a recomposition. BOTH // user files are re-read per generation (the HMR watcher hands us only the // changed file's patches, which one of the reads duplicates — fresh reads // keep the two watchers from stitching in each other's stale copy). @@ -267,19 +259,27 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con ...composed.windowsShellPatches, ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], ...loadOptionalPatches(NAME, homePatchPath()) ?? [], - ...composed.overlayAndFlags, + ...composed.overlays, ]) // One-shot runs exit through the runner; watching would only hold the // process open after its exit request. - const watchProfilePatch = options.task === undefined + const watchProfilePatch = !oneShot // Cloned for the same insert-aliasing reason as composeLive: the boot // application must not mutate the objects later reloads recompose from. const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => { app.current = hostCtx - // Before any config-tree entry mounts, so a plugin that resolves a - // user-facing value at construction already sees this run's layers. + // Before any config-tree entry mounts, so plugins resolve all launch-time + // environment values from the same immutable provenance snapshot. hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment) - if (options.task !== undefined) { + // The command line is a launcher fact every app reads the same way: its + // own arguments, and the bounded exit its startup row requests after + // printing help or rejecting them. + provideCmdline(hostCtx, { + args: options.args, + exit: code => void shutdown.shutdown(code), + ready, + }) + if (oneShot) { const io: HeadlessIo = { stdout: process.stdout, stderr: process.stderr, @@ -287,9 +287,13 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con } hostCtx.provide('headlessIo', io) } - await options.prepare?.(hostCtx, composed.rows) + await options.prepare?.(hostCtx) + }).catch((cause: unknown) => { + bootFailed(cause) + throw cause }) app.current = ctx + bootSettled() // A surface can dispose the whole tree while startup or this post-boot // watcher setup is still in flight. Loader presence and fiber state own // liveness; the local signal fact distinguishes that expected exit race @@ -323,7 +327,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con compose: composeLive, }) } catch (error) { - suppressSignalShutdownError(signalShutdown.signal, error) + if (!signalShutdown.signal.aborted) throw error } } return { ctx, shutdown } diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts deleted file mode 100644 index 8d056d100b..0000000000 --- a/apps/cli/src/web.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * `dsh web` — the browser-surface alias over the profile boot: `--profile web` - * plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag - * becoming a patch over the composed profile - * tree. All web runtime glue (dist serving, prompt section, URL line) lives - * in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives - * flag patches and the LAN-trust snapshot. - * @module @deepseek-ai/dsh/web - */ - -import { networkInterfaces } from 'node:os' -import { fileURLToPath } from 'node:url' -import type { Context } from '@deepseek-ai/cordis' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' -import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' -import { runProfile, type ProfileRows } from './profile-boot.ts' - -const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) - -/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */ -const ALL_INTERFACES_HOST = '0.0.0.0' - -/** - * Non-internal IPv4 interface addresses of this machine — the IP-literal - * authorities an all-interfaces bind is reachable by on the LAN. - * @returns the addresses in interface order (possibly empty). - */ -function lanIPv4Addresses(): string[] { - return Object.values(networkInterfaces()).flat() - .filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal) - .map(iface => iface.address) -} - -/** - * One LAN-trust resolution for one invocation, sampled exactly once: the - * machine's LAN IP literals when the effective bind is all-interfaces, and - * the `trustedHosts` value built from them plus the explicit extras. The - * single sample is deliberate — display must advertise only addresses the - * fence was configured with, so the web-app row receives this same snapshot. - * Derived entries are port-less IP literals: DNS rebinding needs an - * attacker-controlled name, so an IP-literal Host is safe on any port, and - * the bound port may be OS-assigned, unknowable pre-boot. - * @param bindHost - the effective webserver bind host (CLI flag, else the composed row value). - * @param extra - `--trusted-host` values, in argv order. - * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty). - */ -export function resolveLanTrust( - bindHost: string | undefined, - extra: readonly string[], -): { lanAddresses: string[]; trustedHosts: string[] } { - const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [] - return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } -} - -/** The `dsh web` flag family, already parsed by the argument adapter. */ -export interface WebFlags { - patches: string[] - host?: string - port?: number - dev: boolean - trustedHosts?: string[] -} - -/** - * Derive the web alias's flag patches over an already-composed profile tree. - * Patches replace a row's whole config, so each patched row's composed values - * are re-read and merged under the overrides. - * @param rows - the composed row index from {@link composeProfile}. - * @param flags - the parsed flag family. - * @returns the flag patch list, in application order. - */ -function deriveWebFlagPatches( - rows: ProfileRows, - flags: WebFlags, -): PatchOptions[] { - const overrides = new Map<string, Record<string, unknown>>() - const put = (entryId: string, key: string, value: unknown): void => { - const bag = overrides.get(entryId) ?? {} - bag[key] = value - overrides.set(entryId, bag) - } - if (flags.host !== undefined) put('webserver', 'host', flags.host) - if (flags.port !== undefined) put('webserver', 'port', flags.port) - const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host - const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? []) - if (trustedHosts.length > 0) { - // Additive over the composed value: a cordis.patch.yml-configured fence - // authority must survive the derived LAN literals and flag extras — a - // silent drop of security-relevant fence configuration. - const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? [] - put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts]) - } - // mode and lanAddresses are launcher-derived on every boot (--dev also - // inserts the client-hmr row), never pass-throughs of composed values. - put('web-runtime', 'mode', flags.dev ? 'development' : 'production') - put('web-runtime', 'lanAddresses', lanAddresses) - // The agent-preset roots are patched by the shared profile boot: they are - // an assembly fact of every dsh launcher, and `dsh run` composes agents - // from the same roster this alias offers. - const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => { - const composed = rows.get(id) - if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`) - return { id, config: { ...(composed.config ?? {}) as Record<string, unknown>, ...bag } } - }) - if (flags.dev) patches.push({ insert: [{ id: 'client-hmr', name: '@deepseek-ai/dsh-client-hmr' }] }) - return patches -} - -/** - * Whether the composed Web runtime keeps its model- and shell-visible surface - * context. The bundle schema defaults the field to true, so only an explicit - * false suppresses both the bundle contributions and the launcher-owned - * source-checkout section. - * @param rows - the composed Web profile rows before launcher flag patches. - * @returns true unless the web-runtime row explicitly disables surface context. - */ -export function webSurfaceContextEnabled(rows: ProfileRows): boolean { - return (rows.get('web-runtime')?.config as { surfaceContext?: boolean } | undefined)?.surfaceContext !== false -} - -/** - * Serve the browser UI from the web profile. Host/port flags are passed - * through only when given (absent, the composed profile values - * stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on - * every boot. The URL line is printed by the web-app bundle's runtime row - * after Loader settlement. - * @param flags - the parsed `dsh web` flag family. - * @param environment - this run's frozen environment snapshot. - */ -export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise<void> { - await runProfile({ - environment, - profile: 'web', - patchFiles: flags.patches, - deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags), - prepare: (ctx: Context, rows: ProfileRows) => { - if (!webSurfaceContextEnabled(rows)) return - ctx.inject(['systemPrompt'], (promptCtx) => { - addHarnessSourceSection(promptCtx, SOURCE_ROOT) - }) - }, - }) -} diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index e30739214d..ce0a5b2ba4 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -21,22 +21,28 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes profile boots, one-shot runs, and the web alias', () => { - expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] }) + it('routes profile boots and the web alias, handing the rest to the app', () => { + expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [], args: [] }) expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml'])) - .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] }) - expect(parse(['run', 'run', 'the', 'tests'])) - .toEqual({ mode: 'run', profile: 'headless', patches: [], task: 'run the tests' }) - expect(parse(['run', '--profile', 'custom', '--patch', 'a.yml', '--patch', 'b.yml', 'run', 'the', 'tests'])) - .toEqual({ mode: 'run', profile: 'custom', patches: ['a.yml', 'b.yml'], task: 'run the tests' }) - expect(parse(['run', '--', '--profile', 'is', 'task', 'text'])) - .toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' }) - expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) - expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) + .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'], args: [] }) + expect(parse(['web'])).toEqual({ mode: 'profile', profile: 'web', patches: [], args: [] }) + expect(parse(['web', '--patch', 'web.yml'])) + .toEqual({ mode: 'profile', profile: 'web', patches: ['web.yml'], args: [] }) + }) + + it('ends the launcher flags at the first token it does not own', () => { + // App flags, including its -h, and positionals reach the app verbatim. + expect(parse(['--profile', 'tui', '--resume', 'abc'])) + .toEqual({ mode: 'profile', profile: 'tui', patches: [], args: ['--resume', 'abc'] }) + expect(parse(['--profile', 'web', '-h'])) + .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['-h'] }) expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev'])) - .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] }) - expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) - .toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) + .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--host', '0.0.0.0', '--port', '8080', '--dev'] }) + expect(parse(['--profile', 'headless', 'run', 'the', 'tests'])) + .toEqual({ mode: 'profile', profile: 'headless', patches: [], args: ['run', 'the', 'tests'] }) + // Launcher flags placed after that boundary belong to the app too. + expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--resume', 'b', '--patch', 'late.yml'])) + .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml'], args: ['--resume', 'b', '--patch', 'late.yml'] }) }) it('routes the plugin pnpm forwarder', () => { @@ -64,18 +70,12 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] }) }) - it('rejects missing profile, flags outside the current grammar, and contradictory inputs', () => { + it('rejects missing profile, removed flags, and contradictory inputs', () => { expect(exitCode([])).toBe(1) - expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile - expect(exitCode(['--config', 'c.yml'])).toBe(1) // outside the current grammar - expect(exitCode(['-p', 'task'])).toBe(1) // outside the current grammar - expect(exitCode(['--profile', 'headless', 'task'])).toBe(1) // tasks belong to `run` - expect(exitCode(['run'])).toBe(1) - expect(exitCode(['run', ''])).toBe(1) - expect(exitCode(['run', '--profile', '', 'task'])).toBe(1) - expect(exitCode(['run', '--patch=', 'task'])).toBe(1) - expect(exitCode(['--profile', 'headless', 'run', 'task'])).toBe(1) - expect(exitCode(['--patch', 'parent.yml', 'run', 'task'])).toBe(1) + expect(exitCode(['tui'])).toBe(1) // an app argument without --profile has no app to reach + expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed + expect(exitCode(['-p', 'task'])).toBe(1) // removed + expect(exitCode(['run', 'task'])).toBe(1) // app-owned task replaced the launcher subcommand expect(exitCode(['--profile', ''])).toBe(1) expect(exitCode(['--profile', 'x', '--patch='])).toBe(1) expect(exitCode(['--dump-config'])).toBe(1) @@ -87,21 +87,20 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1) expect(exitCode(['web', '--patch='])).toBe(1) - // Boot-free dumps derive no flag patches; silently dropping the flags - // would print a tree that differs from the same invocation's boot. + // A dump never runs the app's startup row, so it cannot show what that + // app's own flags would decide; printing a tree that differs from the same + // invocation's boot would mislead. expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1) - expect(exitCode(['web', '--dump-config', '--dev'])).toBe(1) - // A non-numeric port fails at the flag, not deep in the webserver schema. - expect(exitCode(['web', '--port', 'abc'])).toBe(1) + expect(exitCode(['--profile', 'web', '--dump-config', '-h'])).toBe(1) expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward expect(exitCode(['plugin', '--profile', ''])).toBe(1) expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1) }) - it('exits 0 for help and version', () => { + it('keeps its own help for an invocation with no app to hand it to', () => { expect(exitCode(['--help'])).toBe(0) - expect(exitCode(['run', '--help'])).toBe(0) + expect(exitCode(['-h'])).toBe(0) expect(exitCode(['--version'])).toBe(0) }) }) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 128fbbd42c..c8fc8e8f64 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -193,8 +193,105 @@ function createEnvironmentProbeProfile(home: string, project: string): void { ].join('\n')) } +interface StartupFixture { + home: string + ready: string + echo: string + /** An always-running row's echo, used to observe that a user patch reload landed. */ + witness: string +} + +/** + * A custom profile whose bundle owns a command line: a startup row whose + * `cmdlineArgs` injection identifies it to the launcher, and a row that reads + * what it resolved through a `!!js` config expression. Both plugin modules resolve + * `@deepseek-ai/dsh-cmdline` and `commander` through the profile module + * fallback, exactly as an installed out-of-tree bundle does. + */ +function createStartupFixture(): StartupFixture { + const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-')) + const profileDir = join(home, 'profiles', 'startup') + // Written straight into the installed location: a row module resolves its + // own imports from where it is installed, and only inside the profile does + // Node's parent walk reach the installation fallback these plugins need. + const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle') + mkdirSync(bundleDir, { recursive: true }) + writeFileSync(join(bundleDir, 'startup.mjs'), [ + "import { Command } from 'commander'", + "import { runStartup } from '@deepseek-ai/dsh-cmdline'", + "export const name = 'fixture-startup'", + "export const inject = ['cmdlineArgs']", + 'export function apply(ctx) {', + " const program = new Command().name('fixture').option('--generation <value>', 'echoed generation')", + " return runStartup(ctx, 'fixtureStartup', program, parsed => ({ generation: parsed.opts().generation }))", + '}', + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'waiting.mjs'), [ + "import { writeFileSync } from 'node:fs'", + "import { join } from 'node:path'", + "export const name = 'startup-fixture'", + 'export function apply(ctx, config = {}) {', + ' const heartbeat = setInterval(() => {}, 1000)', + " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))", + " writeFileSync(process.env.RAW_READY_FILE, 'ready')", + ' ctx.effect(() => () => { clearInterval(heartbeat) })', + '}', + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'witness.mjs'), [ + "import { writeFileSync } from 'node:fs'", + "import { join } from 'node:path'", + "export const name = 'reload-witness'", + 'export function apply(ctx, config = {}) {', + " writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))", + '}', + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'cordis.patch.yml'), [ + '- insert:', + ' - id: startup-fixture', + ` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`, + ' inject: [fixtureStartup]', + ' config:', + // The flag the startup row resolved wins over the value written beside it. + " generation: !!js ctx.get('fixtureStartup')?.generation ?? 'bundle-default'", + ' - id: fixture-startup', + ` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`, + ' inject: [cmdlineArgs]', + ' - id: reload-witness', + ` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`, + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({ + name: 'dsh-startup-bundle', + version: '0.0.0', + type: 'module', + dsh: { bundle: { patch: './cordis.patch.yml' } }, + }, undefined, 2)) + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-startup', + private: true, + dependencies: {}, + dsh: { profile: { bundles: ['dsh-startup-bundle'] } }, + }, undefined, 2)) + writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n') + return { home, ready: join(home, 'ready'), echo: join(home, 'config-echo'), witness: join(home, 'witness') } +} + +function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { + return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], { + cwd: fixture.home, + input: '', + reject: false, + timeout: 25_000, + killSignal: 'SIGKILL', + env: { DSH_HOME: fixture.home, RAW_READY_FILE: fixture.ready }, + }) +} + describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { - it('requires --profile and rejects inputs outside the current grammar', async () => { + it('requires --profile and rejects removed commands', async () => { const bare = await runBuiltBin() expect(bare.code).toBe(1) expect(bare.stdout).toBe('') @@ -202,46 +299,63 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const help = await runBuiltBin(['--help']) expect(help.code).toBe(0) expect(help.stdout).toContain('dsh --profile web') - expect(help.stdout).toContain('dsh run "run the tests"') expect(help.stdout).toContain('dsh plugin --profile') expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu) - for (const outsideGrammar of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) { - const result = await runBuiltBin(outsideGrammar) + for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) { + const result = await runBuiltBin(removed) expect(result.code).toBe(1) } }, 30_000) - it('prints run help without initializing the selected profile', async () => { - const parent = mkdtempSync(join(tmpdir(), 'dsh-run-help-')) - const home = join(parent, 'not-created') + it('routes help and usage errors without activating startup-dependent rows', async () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-')) try { - const result = await runBuiltBin(['run', '--help'], { DSH_HOME: home }) - expect(result.code).toBe(0) - expect(result.stderr).toBe('') - expect(result.stdout).toContain('Usage: dsh run [options] <task...>') - expect(existsSync(home)).toBe(false) - } finally { - rmSync(parent, { recursive: true, force: true }) - } - }) + const web = await runBuiltBin(['--profile', 'web', '--help'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(web.code).toBe(0) + expect(web.stderr).toBe('') + expect(web.stdout).toContain('Usage: dsh --profile web') + expect(web.stdout).toContain('--port <port>') + expect(web.stdout).not.toContain('dsh web: http://') - it('runs the default headless profile through the published run command', async () => { - const apiKey = 'built-dsh-run-key' + const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(headlessHelp.code).toBe(0) + expect(headlessHelp.stderr).toBe('') + expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless') + + const missingTask = await runBuiltBin(['--profile', 'headless'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(missingTask.code).toBe(1) + expect(missingTask.stderr).toContain('a task is required') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }, 30_000) + + it('runs the headless profile through its app-owned task positional', async () => { + const apiKey = 'built-dsh-headless-key' const server = await startMockLlmServer({ sequence: ['success'], apiKey, - successText: 'published dsh run reached the mock', + successText: 'published headless profile reached the mock', }) - const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-')) + const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-')) try { - const result = await runBuiltBin(['run', 'answer', 'from', 'the', 'published', 'entry'], { + const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: apiKey, DEEPSEEK_BASE_URL: server.baseURL, }) expect(result.code, result.stderr).toBe(0) - expect(result.stdout).toBe('published dsh run reached the mock') + expect(result.stdout).toBe('published headless profile reached the mock') expect(result.stderr).toBe('') expect(server.requests.length).toBeGreaterThan(0) expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true) @@ -317,9 +431,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', }, 30_000) it('reports a patch-overlay boot failure without hanging', async () => { - // An HMR main-watcher initial scan that refreshes the include - // mid-initial-apply deadlocks the failing apply's rollback against the - // refresh drain: dsh exits 13 with no diagnostic instead of settling + // The HMR main watcher's initial scan once refreshed the include + // mid-initial-apply, deadlocking the failing apply's rollback against the + // refresh drain: dsh exited 13 with no diagnostic instead of settling // ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)). const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-')) try { @@ -336,6 +450,18 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('rejects arguments when no active row injects the profile command line', async () => { + const fixture = createProfileLifecycleFixture() + try { + const result = await runBuiltBin(['--profile', 'lifecycle', '--help'], { DSH_HOME: fixture.home }) + expect(result.code).toBe(1) + expect(result.stderr).toContain('takes no app arguments because no active row injects cmdlineArgs') + expect(existsSync(fixture.ready)).toBe(false) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + it('applies a custom profile bundle and disposes it on a startup-time signal', async () => { const fixture = createProfileLifecycleFixture() const child = startProfileLifecycle(fixture) @@ -404,6 +530,83 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('hands the app arguments to the profile, which applies them before its rows start', async () => { + const fixture = createStartupFixture() + const child = startStartupProfile(fixture, ['--generation', 'flagged']) + try { + await waitForFile(fixture.ready) + // The waiting row started once, already carrying the flag value: the + // launcher never saw --generation, and the app resolved it first. + expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') + child.kill('SIGTERM') + expect((await child).exitCode).toBe(0) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + it('starts a waiting row on its composed value when the invocation carries no app arguments', async () => { + const fixture = createStartupFixture() + const child = startStartupProfile(fixture, []) + try { + await waitForFile(fixture.ready) + expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default') + child.kill('SIGTERM') + expect((await child).exitCode).toBe(0) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + it('keeps the app arguments across a user patch reload', async () => { + // A live edit recomposes every row while the startup service remains + // active, so each config expression reads the same invocation value (a + // served port does not move back to its composed fallback). + const fixture = createStartupFixture() + const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml') + const child = startStartupProfile(fixture, ['--generation', 'flagged']) + try { + // Both rows: the waiting one carries the flag value, and the witness is + // what a reload will re-mount. They start independently, so neither + // marker implies the other. + await waitForFile(fixture.ready) + await waitForFile(fixture.witness) + expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') + // An edit to an unrelated row: the witness re-mounts, which is how this + // test knows the whole tree was recomposed. + rmSync(fixture.witness) + writeFileSync(profilePatch, [ + '- id: reload-witness', + ' config:', + ' generation: reloaded', + '', + ].join('\n')) + await waitForFile(fixture.witness) + expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded') + expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') + child.kill('SIGTERM') + expect((await child).exitCode).toBe(0) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + it("prints the app's own help, starts none of its rows, and exits", async () => { + const fixture = createStartupFixture() + try { + const result = await startStartupProfile(fixture, ['--help']) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Usage: fixture') + expect(result.stdout).toContain('--generation') + expect(existsSync(fixture.ready)).toBe(false) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + it('anchors a relative add spec to the invoking directory, not the profile', async () => { // `dsh plugin --profile x add .` from a plugin checkout must install THAT // checkout — pnpm's cwd is the profile directory, so an un-anchored `.` @@ -490,20 +693,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") }, 30_000) - it('prints a headless profile with no Host, HTTP, or browser rows', async () => { - const { stdout, code, stderr } = await runBuiltBin( - ['--profile', 'headless', '--dump-default-config'], - { DSH_HOME: home }, - ) - expect(code).toBe(0) - expect(stderr).toBe('') - expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-default-model'") - expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'") - expect(stdout).not.toContain("name: '@deepseek-ai/dsh-host-") - expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'") - expect(stdout).not.toContain("name: '@deepseek-ai/dsh-client-") - }, 30_000) - it('composes the profile user layer and a --patch overlay in order', async () => { // Auto-init the web profile first, then write its user layer. const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home }) diff --git a/apps/cli/tests/trusted-hosts.spec.ts b/apps/cli/tests/trusted-hosts.spec.ts deleted file mode 100644 index 5647d01536..0000000000 --- a/apps/cli/tests/trusted-hosts.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */ - -import { describe, expect, it, vi } from 'vitest' -import { resolveLanTrust, webSurfaceContextEnabled } from '../src/web.ts' - -vi.mock('node:os', () => ({ - networkInterfaces: () => ({ - lo0: [ - { family: 'IPv4', internal: true, address: '127.0.0.1' }, - ], - en0: [ - { family: 'IPv6', internal: false, address: 'fe80::1' }, - { family: 'IPv4', internal: false, address: '192.168.1.5' }, - ], - en1: [ - { family: 'IPv4', internal: false, address: '10.0.0.7' }, - ], - utun0: undefined, - }), -})) - -describe('resolveLanTrust', () => { - it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => { - const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080']) - expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7']) - expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) - }) - - it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => { - expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] }) - expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) - }) -}) - -describe('webSurfaceContextEnabled', () => { - it('defaults to enabled and honors an explicit complete-prompt disable', () => { - expect(webSurfaceContextEnabled(new Map())).toBe(true) - expect(webSurfaceContextEnabled(new Map([ - ['web-runtime', { config: { mode: 'production' } }], - ]))).toBe(true) - expect(webSurfaceContextEnabled(new Map([ - ['web-runtime', { config: { surfaceContext: false } }], - ]))).toBe(false) - }) -}) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index d830e8fba6..a288a5aa95 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../packages/boot/app-boot" }, + { + "path": "../../packages/ui/cmdline" + }, { "path": "../../packages/bundle/base" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a05f336f3e..5ae88ddf96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,9 @@ importers: '@deepseek-ai/dsh-client-ui-agent-preset': specifier: workspace:^ version: link:../../packages/client/ui-agent-preset + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../packages/boot/cmdline '@deepseek-ai/dsh-command-compact': specifier: workspace:^ version: link:../../packages/compact/command-compact From f749e048812a7c7bc0977bfbe4ab081d43877904 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Thu, 6 Aug 2026 20:52:26 +0800 Subject: [PATCH 157/229] docs: record how an app comes to own its command line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agent Note keeps the three vendored-Loader facts the mechanism turns on — a row's config is resolved and validated when its fiber is created, while it is still waiting; an inject update loses the plugin's static injections; a row cannot be inserted from inside a mounting plugin — with the alternatives they ruled out. --- ...026-08-06-app-owned-command-line.i18n.yaml | 6 +++ .../2026-08-06-app-owned-command-line.md | 45 +++++++++++++++++++ .../2026-08-06-app-owned-command-line.zh.md | 45 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md create mode 100644 .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml new file mode 100644 index 0000000000..11165122e5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +2026-08-06-app-owned-command-line.md: f7db56e298c11f2663f63cc05d71121a03856668 +2026-08-06-app-owned-command-line.zh.md: f17fdc9a78d9baa36852392e11744a585adfe578 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md new file mode 100644 index 0000000000..f7db56e298 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -0,0 +1,45 @@ +# Agent Note: Apps own their command line through `ctx.cmdlineArgs` + +Status: implemented + +English | [中文](2026-08-06-app-owned-command-line.zh.md) + +## Problem + +After profiles, compositions were installable but their command lines were not. `apps/cli` still declared the Web flag family (`--host`, `--port`, `--dev`, `--workspace-root`, `--trusted-host`) and the one-shot task positional, then derived patches for row ids it hardcoded (`webserver`, `api-gateway`, `connection`, `web-runtime`). An out-of-tree app such as [turtle-ui](https://github.com/deepseek-harness/turtle-ui) could contribute rows but had no way to accept a flag: `dsh --profile tui --resume <session>` had nowhere to be parsed, and `dsh --profile web --help` printed the launcher's help rather than the web app's. + +## Decision + +The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. + +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`), `ctx.appExit`, and `ctx.appPatches`. An app consumes them from a **startup row** that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)` with its own commander program; rows the app configures inject that startup service in the bundle patch, so they cannot start before their values are resolved, and `--help` prints, disables those rows, and exits without the app ever starting. + +The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any row id. Out of tree, turtle-ui gained `--resume <session>` / `--session <id>` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. + +Two further consequences fell out of review. An app's decisions are also handed back to the launcher as patches (`ctx.appPatches`), because the launcher re-applies its whole patch stack when a user edits a live patch file: without that layer, an unrelated edit rebuilt every row from its composed options and silently moved a server started on `--port 8080` back to the composed port, dropping `--dev` and the derived `/api` fence authorities with it. And `dsh --profile web` now adds the harness-source prompt section that only the `dsh web` alias used to add — the two paths finally boot identically, which also means a user profile named `web` inherits it. + +## How a waiting row actually receives its values + +Three vendored-Loader facts shaped the mechanism, all found by probe: + +- **A row's config is resolved when the Loader creates its fiber, which happens while the row is still waiting for its startup service.** Writing a new config onto that waiting fiber never reaches the plugin. Each changed row is therefore recycled — disabled, then re-enabled with its new values — which drops the stale fiber and resolves the config again. +- **Updating a row's `inject` loses the plugin's own static injections.** The Loader restarts a replaced row from `runtime.callback`, the unwrapped function, and `Inject.resolve(plugin.inject)` then finds nothing: a row declaring `inject = ['httpServer', 'apiProxy']` comes back unable to read either. Recycling therefore never touches `inject`; the waiting rows are released by providing the service. +- **A row's config is validated at fiber creation too**, so a row whose *required* config the startup supplies (the one-shot runner's `task`) must ship `disabled: true`; making it wait is not enough, because the boot fails before the startup row can run. It only appeared to work because the startup module happened to import first. + +A related constraint: a row cannot be inserted from inside a mounting plugin (`tree.create` returns a prefixed id it then fails to resolve), so a conditional row ships `disabled: true` and startup enables it. Recycling also lets a still-in-flight mount settle first, since disabling alone is not a barrier. + +## Alternatives considered + +- **Releasing the rows by clearing their `inject`** (one atomic update per row): it worked in isolation and failed on the real web tree, because clearing `inject` is exactly what loses the plugin's static injections. The failure is silent until a plugin reads a service it declared. +- **Reading flags from the row's config through `!!js ctx.get('webStartup')`**: config expressions are interpolated when the fiber is created, before the startup service exists, so every waiting row would read `undefined`. +- **The launcher running each bundle's startup function before boot** (no cordis involvement): simplest and strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. The maintainer's ruling was a startup *service* other rows depend on, which keeps one protocol. +- **Both apps parsing the same argv** (the one-shot bundle rides over the web bundle): two parsers cannot both own `-h`. A composition has exactly one command-line owner: the layering bundle disables the underlying startup row and names both startup services, so the absorbed rows start on their composed values. +- **`instanceof CommanderError`**: an out-of-tree plugin brings its own commander copy, so the class identity differs and a printed `--help` was rethrown as a fatal load failure. Commander's control-flow errors are detected structurally instead. + +## Consequences + +- An app's flags, help text, and usage errors live with the rows they configure; adding a flag to an installed plugin needs no launcher change. +- `--help` cost is a boot: the tree mounts far enough for the startup row to run, then tears down. The rows waiting on that app never start, which is what the maintainer accepted when choosing the service-shaped design. +- A startup service has no statically declared owner: a bundle shipping waiting rows without its startup row fails at settlement with pending entries naming the service, not at load. +- Launcher flags must precede app arguments; a first app argument reading `web` or `plugin` selects those subcommands instead, and the launcher's parser consumes one `--`, so a literal `--` for the app needs `-- --`. +- `--dump-config` never runs a startup row, so it prints the composition before any app argument is resolved and rejects an invocation that carries app arguments. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md new file mode 100644 index 0000000000..f17fdc9a78 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 应用通过 `ctx.cmdlineArgs` 持有自己的命令行 + +Status: implemented + +[English](2026-08-06-app-owned-command-line.md) | 中文 + +## 问题 + +profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍然声明着 Web flag 家族(`--host`、`--port`、`--dev`、`--workspace-root`、`--trusted-host`)和一次性任务位置参数,再为自己硬编码的行 id(`webserver`、`api-gateway`、`connection`、`web-runtime`)派生 patch。像 [turtle-ui](https://github.com/deepseek-harness/turtle-ui) 这样的树外应用能贡献行,却无处接受一个 flag:`dsh --profile tui --resume <session>` 没有地方可供解析,而 `dsh --profile web --help` 打印的是启动器的 help,而不是 web 应用的 help。 + +## 决策 + +启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 + +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)、`ctx.appExit` 和 `ctx.appPatches`。应用从**启动行**消费它们:启动行注入 `cmdlineArgs`,并以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`;应用所配置的行在组合包 patch 中注入这个启动服务,因此在取值解析完成之前无法启动,而 `--help` 会打印文本、禁用这些行并退出,应用自始至终不会启动。 + +已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何行 id。在树外,turtle-ui 以同样的方式获得了 `--resume <session>` / `--session <id>`,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 + +评审中还落出两条后果。应用的决策同时以 patch 的形式交还给启动器(`ctx.appPatches`),因为用户编辑一个活动的 patch 文件时,启动器会重新施加自己的整个 patch 栈:没有这一层,一次无关的编辑就会把每一行都从其组合出的选项重建出来,把一台以 `--port 8080` 启动的服务器悄悄挪回组合出的端口,并连带丢掉 `--dev` 和由此派生的 `/api` 围栏 authority。另外,`dsh --profile web` 现在也会加上过去只有 `dsh web` 别名才会加的 harness 源码提示词章节 —— 两条路径终于以完全相同的方式引导,这也意味着名为 `web` 的用户 profile 会继承它。 + +## 等待中的行实际如何拿到自己的取值 + +vendored Loader 的三个事实塑造了这套机制,三者都是靠探针试出来的: + +- **行的配置在 Loader 创建其 fiber 时就已解析,而这发生在它仍在等待自己启动服务的时候。** 把新配置写到这个等待中的 fiber 上,永远到不了插件。因此每个改动过的行都会被回收重建:先禁用,再带着新取值重新启用,从而丢弃陈旧的 fiber 并重新解析配置。 +- **更新一行的 `inject` 会丢失插件自身的静态注入。** Loader 从 `runtime.callback`(未经包装的函数)重启被替换的行,此时 `Inject.resolve(plugin.inject)` 什么也找不到:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。因此回收重建绝不触碰 `inject`;等待中的行是靠提供服务来放行的。 +- **行的配置同样在 fiber 创建时被校验**,因此一个*必填*配置由启动流程提供的行(一次性运行器的 `task`)必须以 `disabled: true` 交付;只让它等待并不够,因为 boot 会在启动行得以运行之前就失败。它之所以看起来能工作,只是因为启动模块碰巧先被 import。 + +还有一条相关约束:不能从正在挂载的插件内部插入一行(`tree.create` 返回一个带前缀的 id,随后它自己解析不出来),因此条件性的行以 `disabled: true` 交付,由启动流程启用。回收重建还会先让某次仍在进行中的挂载结算完毕,因为单靠禁用并不构成屏障。 + +## 曾考虑的替代方案 + +- **通过清空行的 `inject` 来放行**(每行一次原子更新):孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 +- **通过 `!!js ctx.get('webStartup')` 从行配置中读取 flag**:配置表达式在 fiber 创建时求值,早于启动服务存在,因此每个等待中的行都会读到 `undefined`。 +- **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):最简单,而且严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。维护者的裁定是做成其他行所依赖的启动*服务*,从而只保留一套协议。 +- **两个应用解析同一份 argv**(一次性组合包叠加在 web 组合包之上):两个解析器不可能同时持有 `-h`。一套组合有且只有一个命令行所有者:叠加的组合包禁用下层的启动行,并同时提供这两个启动服务,使被吸收的行按组合后的取值启动。 +- **`instanceof CommanderError`**:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的 `--help` 会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。 + +## 后果 + +- 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。 +- `--help` 的代价是一次 boot:配置树挂载到足以运行启动行,随后拆除。等待该应用的行从不启动,这正是维护者选择服务形态的设计时所接受的代价。 +- 启动服务没有静态声明的所有者:交付了等待中的行却缺少对应启动行的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 +- 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好是 `web` 或 `plugin`,选中的将是这两个子命令,而且启动器的解析器会消耗掉一个 `--`,因此要给应用传一个字面量 `--` 需要写成 `-- --`。 +- `--dump-config` 从不运行启动行,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。 From 1f0a0440f3e8c52824fb0c6499117c103362c47e Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 7 Aug 2026 11:58:03 +0800 Subject: [PATCH 158/229] refactor(cmdline)!: an app's entrypoint provides values its rows read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the patch round trip. An app's entrypoint resolves the command line into a service, and the rows it configures read that service from their own config — port: !!js ctx.get('webStartup')?.port ?? 3080 — so the resolved value beats the value written beside it and nothing is written back into a row or handed to the launcher. A bundle names the entrypoint row in its manifest (dsh.bundle.entrypoint), which is what lets the boot mount in two passes: entrypoints alone, then the whole composition. That ordering is required, not cosmetic — a row's config expressions are evaluated when the include applies the row, and a strict ctx.get only answers for a service whose providing fiber is already active. What this removes: ctx.appPatches and the launcher-owned patch layer, the disable/re-enable recycle and its in-flight-mount barrier, overrideConfig, and the reload hazard they existed for. A live config edit now re-applies the second pass against services that are still up, so a served port survives by construction. What it adds: ctx.appReady, because Loader settlement no longer means the app is up — a row mounted in the second pass can observe a settled tree while that pass is still running, or already rolling back. The web URL line waits for it, so a boot that fails in the second pass announces nothing. --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 31 ++- .../2026-08-06-app-owned-command-line.zh.md | 31 ++- docs/config-catalog.md | 2 +- packages/boot/app-boot/src/index.ts | 26 ++ packages/boot/app-boot/src/profile.ts | 50 +++- packages/boot/app-boot/tests/profile.spec.ts | 32 +++ .../boot/app-boot/tests/user-patches.spec.ts | 86 ++++++- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 43 ++-- packages/boot/cmdline/README.zh.md | 43 ++-- packages/boot/cmdline/src/index.ts | 230 +++++++---------- packages/boot/cmdline/tests/cmdline.spec.ts | 232 +++++++----------- packages/bundle/headless/cordis.patch.yml | 7 +- packages/bundle/headless/package.json | 3 +- packages/bundle/headless/src/startup.ts | 35 ++- .../bundle/headless/tests/startup.spec.ts | 111 ++++----- packages/bundle/web-app/cordis.patch.yml | 37 ++- packages/bundle/web-app/package.json | 3 +- packages/bundle/web-app/src/index.ts | 24 +- packages/bundle/web-app/src/startup.ts | 85 +++---- packages/bundle/web-app/tests/startup.spec.ts | 162 ++++++------ packages/bundle/web-app/tests/web-app.spec.ts | 32 +++ 23 files changed, 720 insertions(+), 593 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 11165122e5..c0ec7836ae 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: f7db56e298c11f2663f63cc05d71121a03856668 -2026-08-06-app-owned-command-line.zh.md: f17fdc9a78d9baa36852392e11744a585adfe578 +2026-08-06-app-owned-command-line.md: 4765629c0cc3fee1d850de215af18bdbe51324bb +2026-08-06-app-owned-command-line.zh.md: 48782fbb9ce53ba9b3e8dbc6c2f746c7f1d46ea1 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index f7db56e298..4765629c0c 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -12,34 +12,39 @@ After profiles, compositions were installable but their command lines were not. The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. -The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`), `ctx.appExit`, and `ctx.appPatches`. An app consumes them from a **startup row** that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)` with its own commander program; rows the app configures inject that startup service in the bundle patch, so they cannot start before their values are resolved, and `--help` prints, disables those rows, and exits without the app ever starting. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`), `ctx.appExit`, and `ctx.appReady`. An app consumes them from its **entrypoint row** — named by its bundle manifest (`dsh.bundle.entrypoint`) — which injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)` with its own commander program, then provides what it resolved as its own service. The rows the app configures read that service from their own config expressions (`port: !!js ctx.get('webStartup')?.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. + +The boot mounts in two passes, which is what the manifest declaration buys: entrypoints alone, then the whole composition. A row's config expressions are evaluated when the include applies the row, and a strict `ctx.get` only answers for a service whose providing fiber is active, so the rest of the tree has to be applied after the entrypoints are up. `--help` therefore exits before the second pass exists, and a user editing a live patch file re-applies that pass against services that are still up, so a served port cannot be silently reset. The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any row id. Out of tree, turtle-ui gained `--resume <session>` / `--session <id>` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. -Two further consequences fell out of review. An app's decisions are also handed back to the launcher as patches (`ctx.appPatches`), because the launcher re-applies its whole patch stack when a user edits a live patch file: without that layer, an unrelated edit rebuilt every row from its composed options and silently moved a server started on `--port 8080` back to the composed port, dropping `--dev` and the derived `/api` fence authorities with it. And `dsh --profile web` now adds the harness-source prompt section that only the `dsh web` alias used to add — the two paths finally boot identically, which also means a user profile named `web` inherits it. +Two further consequences. Loader settlement stopped meaning "the app is up" — a row mounted in the second pass can observe a settled tree while the pass that mounted it is still going, or already rolling back — so a row that publishes readiness (the web URL line) awaits `ctx.appReady` instead. And `dsh --profile web` now adds the harness-source prompt section that only the `dsh web` alias used to add: the two paths finally boot identically, which also means a user profile named `web` inherits it. -## How a waiting row actually receives its values +## Why the boot has phases -Three vendored-Loader facts shaped the mechanism, all found by probe: +Four vendored-Loader facts shaped the mechanism, all found by probe: -- **A row's config is resolved when the Loader creates its fiber, which happens while the row is still waiting for its startup service.** Writing a new config onto that waiting fiber never reaches the plugin. Each changed row is therefore recycled — disabled, then re-enabled with its new values — which drops the stale fiber and resolves the config again. -- **Updating a row's `inject` loses the plugin's own static injections.** The Loader restarts a replaced row from `runtime.callback`, the unwrapped function, and `Inject.resolve(plugin.inject)` then finds nothing: a row declaring `inject = ['httpServer', 'apiProxy']` comes back unable to read either. Recycling therefore never touches `inject`; the waiting rows are released by providing the service. -- **A row's config is validated at fiber creation too**, so a row whose *required* config the startup supplies (the one-shot runner's `task`) must ship `disabled: true`; making it wait is not enough, because the boot fails before the startup row can run. It only appeared to work because the startup module happened to import first. +- **A profile's rows arrive as the root include's `patches` option, and an entry's whole config is interpolated when that entry starts.** Every `!!js` in every row is therefore evaluated once, when the include mounts — before any row exists. Rows in the root config *file* would interpolate per row, but a profile root is empty by design. +- **A strict `ctx.get` hides a service whose providing fiber is not yet ACTIVE**, and a plugin's own fiber is not active while its `apply` is still running. Providing a service and configuring rows from it in the same pass cannot work. +- **Updating a row's `inject` loses the plugin's own static injections.** The Loader restarts a replaced row from `runtime.callback`, the unwrapped function, and `Inject.resolve(plugin.inject)` then finds nothing: a row declaring `inject = ['httpServer', 'apiProxy']` comes back unable to read either. +- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and a row that mounts beside it enables it (`dsh web --dev` and its reload chain). -A related constraint: a row cannot be inserted from inside a mounting plugin (`tree.create` returns a prefixed id it then fails to resolve), so a conditional row ships `disabled: true` and startup enables it. Recycling also lets a still-in-flight mount settle first, since disabling alone is not a barrier. +Together these rule out configuring rows from a service in one pass, and rule in the phased mount: rows keep their own `inject` and their own config, and the only thing the launcher does between phases is apply the composition again. ## Alternatives considered -- **Releasing the rows by clearing their `inject`** (one atomic update per row): it worked in isolation and failed on the real web tree, because clearing `inject` is exactly what loses the plugin's static injections. The failure is silent until a plugin reads a service it declared. -- **Reading flags from the row's config through `!!js ctx.get('webStartup')`**: config expressions are interpolated when the fiber is created, before the startup service exists, so every waiting row would read `undefined`. -- **The launcher running each bundle's startup function before boot** (no cordis involvement): simplest and strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. The maintainer's ruling was a startup *service* other rows depend on, which keeps one protocol. +- **Writing the resolved values into each row** (a config update per row, plus a patch layer handed back to the launcher so a reload could not undo it): it worked, but it meant patches travelling from an app to the launcher and back, two mechanisms for one fact, and a recycle whose correctness depended on Loader restart internals. The maintainer rejected the round trip; the service the rows read replaced all of it. +- **Releasing rows by clearing their `inject`**: it worked in isolation and failed on the real web tree, because clearing `inject` is exactly what loses the plugin's static injections. The failure is silent until a plugin reads a service it declared. +- **Rows waiting on the service in a single-pass mount**: the config expressions are interpolated before any row exists, so every reader would see `undefined`. +- **The launcher running each bundle's startup function before boot** (no cordis involvement): strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. Declaring an entrypoint *row* keeps one protocol: the entrypoint is an ordinary row, dumpable and patchable, and a layering bundle disables it like any other. - **Both apps parsing the same argv** (the one-shot bundle rides over the web bundle): two parsers cannot both own `-h`. A composition has exactly one command-line owner: the layering bundle disables the underlying startup row and names both startup services, so the absorbed rows start on their composed values. - **`instanceof CommanderError`**: an out-of-tree plugin brings its own commander copy, so the class identity differs and a printed `--help` was rethrown as a fatal load failure. Commander's control-flow errors are detected structurally instead. ## Consequences - An app's flags, help text, and usage errors live with the rows they configure; adding a flag to an installed plugin needs no launcher change. -- `--help` cost is a boot: the tree mounts far enough for the startup row to run, then tears down. The rows waiting on that app never start, which is what the maintainer accepted when choosing the service-shaped design. -- A startup service has no statically declared owner: a bundle shipping waiting rows without its startup row fails at settlement with pending entries naming the service, not at load. +- `--help` mounts only the entrypoints and exits, so nothing else in the composition ever starts. +- A startup service has no statically declared owner: a bundle shipping reading rows without its entrypoint fails at settlement with pending entries naming the service, not at load. +- A user patch that replaces a row's whole `config` drops its expressions, and with them the flag's precedence for that row. - Launcher flags must precede app arguments; a first app argument reading `web` or `plugin` selects those subcommands instead, and the launcher's parser consumes one `--`, so a literal `--` for the app needs `-- --`. - `--dump-config` never runs a startup row, so it prints the composition before any app argument is resolved and rejects an invocation that carries app arguments. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index f17fdc9a78..48782fbb9c 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -12,34 +12,39 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 -新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)、`ctx.appExit` 和 `ctx.appPatches`。应用从**启动行**消费它们:启动行注入 `cmdlineArgs`,并以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`;应用所配置的行在组合包 patch 中注入这个启动服务,因此在取值解析完成之前无法启动,而 `--help` 会打印文本、禁用这些行并退出,应用自始至终不会启动。 +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)、`ctx.appExit` 和 `ctx.appReady`。应用从自己的**入口点行**消费它们——该行由其组合包 manifest(元数据清单)点名(`dsh.bundle.entrypoint`),注入 `cmdlineArgs`,以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。应用所配置的行从各自的配置表达式中读取该服务(`port: !!js ctx.get('webStartup')?.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 + +boot 分两趟挂载,这正是 manifest 声明所换来的:先是各入口点,然后才是整套组合。行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答,因此配置树的其余部分必须在入口点起来之后才施加。于是 `--help` 在第二趟存在之前就退出;用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新施加,因此已经服务中的端口不会被悄悄重置。 已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何行 id。在树外,turtle-ui 以同样的方式获得了 `--resume <session>` / `--session <id>`,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 -评审中还落出两条后果。应用的决策同时以 patch 的形式交还给启动器(`ctx.appPatches`),因为用户编辑一个活动的 patch 文件时,启动器会重新施加自己的整个 patch 栈:没有这一层,一次无关的编辑就会把每一行都从其组合出的选项重建出来,把一台以 `--port 8080` 启动的服务器悄悄挪回组合出的端口,并连带丢掉 `--dev` 和由此派生的 `/api` 围栏 authority。另外,`dsh --profile web` 现在也会加上过去只有 `dsh web` 别名才会加的 harness 源码提示词章节 —— 两条路径终于以完全相同的方式引导,这也意味着名为 `web` 的用户 profile 会继承它。 +还有两条后果。Loader 结算不再意味着「应用已经起来」——在第二趟中挂载的行可能看到一棵已结算的树,而挂载它的那一趟仍在进行,甚至已经在回滚——因此公布就绪信号的行(web 的 URL 行)改为等待 `ctx.appReady`。另外,`dsh --profile web` 现在也会加上过去只有 `dsh web` 别名才会加的 harness 源码提示词章节:两条路径终于以完全相同的方式引导,这也意味着名为 `web` 的用户 profile 会继承它。 -## 等待中的行实际如何拿到自己的取值 +## 为什么 boot 分阶段 -vendored Loader 的三个事实塑造了这套机制,三者都是靠探针试出来的: +vendored Loader 的四个事实塑造了这套机制,它们都是靠探针试出来的: -- **行的配置在 Loader 创建其 fiber 时就已解析,而这发生在它仍在等待自己启动服务的时候。** 把新配置写到这个等待中的 fiber 上,永远到不了插件。因此每个改动过的行都会被回收重建:先禁用,再带着新取值重新启用,从而丢弃陈旧的 fiber 并重新解析配置。 -- **更新一行的 `inject` 会丢失插件自身的静态注入。** Loader 从 `runtime.callback`(未经包装的函数)重启被替换的行,此时 `Inject.resolve(plugin.inject)` 什么也找不到:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。因此回收重建绝不触碰 `inject`;等待中的行是靠提供服务来放行的。 -- **行的配置同样在 fiber 创建时被校验**,因此一个*必填*配置由启动流程提供的行(一次性运行器的 `task`)必须以 `disabled: true` 交付;只让它等待并不够,因为 boot 会在启动行得以运行之前就失败。它之所以看起来能工作,只是因为启动模块碰巧先被 import。 +- **profile 的各行是作为根 include 的 `patches` 选项送达的,而一个条目的整份配置会在该条目启动时被插值。** 因此每一行里的每个 `!!js` 都会在 include 挂载时一次性求值——早于任何行的存在。位于根配置*文件*中的行会逐行插值,但 profile 的根按设计就是空的。 +- **严格的 `ctx.get` 会隐藏提供方 fiber 尚未 ACTIVE 的服务**,而插件自身的 fiber 在其 `apply` 仍在运行时并未 active。在同一趟里既提供服务又用它配置各行,是不可能成立的。 +- **更新一行的 `inject` 会丢失插件自身的静态注入。** Loader 从 `runtime.callback`(未经包装的函数)重启被替换的行,此时 `Inject.resolve(plugin.inject)` 什么也找不到:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。 +- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,由与它同趟挂载的行来启用(`dsh web --dev` 及其重载链路)。 -还有一条相关约束:不能从正在挂载的插件内部插入一行(`tree.create` 返回一个带前缀的 id,随后它自己解析不出来),因此条件性的行以 `disabled: true` 交付,由启动流程启用。回收重建还会先让某次仍在进行中的挂载结算完毕,因为单靠禁用并不构成屏障。 +这些事实合起来排除了「一趟之内用服务配置各行」,并确立了分阶段挂载:各行保留自己的 `inject` 和自己的配置,而启动器在两阶段之间所做的,仅仅是再施加一次组合。 ## 曾考虑的替代方案 -- **通过清空行的 `inject` 来放行**(每行一次原子更新):孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 -- **通过 `!!js ctx.get('webStartup')` 从行配置中读取 flag**:配置表达式在 fiber 创建时求值,早于启动服务存在,因此每个等待中的行都会读到 `undefined`。 -- **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):最简单,而且严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。维护者的裁定是做成其他行所依赖的启动*服务*,从而只保留一套协议。 +- **把解析出的取值写进每一行**(逐行一次配置更新,外加交还给启动器的一层 patch,使重载无法撤销它):它能工作,但这意味着 patch 在应用与启动器之间来回传递、同一件事有两套机制,以及一套其正确性依赖 Loader 重启内部细节的回收重建。维护者否决了这次往返;供各行读取的服务取代了这一切。 +- **通过清空行的 `inject` 来放行**:孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 +- **在单趟挂载中让各行等待该服务**:配置表达式在任何行存在之前就已插值,因此每个读取方都会看到 `undefined`。 +- **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。声明一个入口点*行*则只保留一套协议:入口点就是一个普通的行,可 dump、可 patch,叠加的组合包也能像禁用其他行那样禁用它。 - **两个应用解析同一份 argv**(一次性组合包叠加在 web 组合包之上):两个解析器不可能同时持有 `-h`。一套组合有且只有一个命令行所有者:叠加的组合包禁用下层的启动行,并同时提供这两个启动服务,使被吸收的行按组合后的取值启动。 - **`instanceof CommanderError`**:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的 `--help` 会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。 ## 后果 - 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。 -- `--help` 的代价是一次 boot:配置树挂载到足以运行启动行,随后拆除。等待该应用的行从不启动,这正是维护者选择服务形态的设计时所接受的代价。 -- 启动服务没有静态声明的所有者:交付了等待中的行却缺少对应启动行的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 +- `--help` 只挂载各入口点然后退出,组合中的其余部分从不启动。 +- 启动服务没有静态声明的所有者:交付了读取行却缺少对应入口点的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 +- 用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉,该行上 flag 的优先级也随之消失。 - 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好是 `web` 或 `plugin`,选中的将是这两个子命令,而且启动器的解析器会消耗掉一个 `--`,因此要给应用传一个字面量 `--` 需要写成 `-- --`。 - `--dump-config` 从不运行启动行,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 64c7722a3c..7cd481e6d5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2546,7 +2546,7 @@ export interface Config { export type WebMode = 'production' | 'development' ``` -Source: [`packages/bundle/web-app/src/index.ts:32`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:36`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 854da5646a..e5596229ae 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -39,6 +39,7 @@ export { PROFILES_DIR, readProfileManifest, resolveBundleDir, + resolveEntrypoints, resolveProfileDir, writeProfileManifest, type DshBundleManifest, @@ -527,6 +528,31 @@ export async function mountRootInclude( return entry } +/** + * Re-apply the root include's patch list on a booted tree, and wait for the + * result to settle. + * + * This is how a boot mounts its composition in phases: an app's entrypoint row + * resolves what the rest of the tree reads (`!!js ctx.get('webStartup')?.port`), + * and a row's config expressions are evaluated when the include applies them — + * so the rest of the composition must be applied after the entrypoints are + * active, not before. + * @param ctx - the booted context whose root include to re-apply. + * @param patches - the full patch list for this generation. + * @returns nothing once the new generation has settled; a disposed tree is a no-op. + * @throws when the tree was booted without the root include. + */ +export async function applyRootPatches(ctx: Context, patches: readonly PatchOptions[]): Promise<void> { + const entry = bootstrapIncludes.get(ctx) + if (entry === undefined) throw new Error('dsh: applying root patches requires the root Include entry') + // A surface can dispose the whole tree while an entrypoint is still parsing + // (`--help`, or an early SIGTERM); there is then nothing left to mount. + if (ctx.get('loader') === undefined) return + const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config + await entry.update({ config: { ...includeConfig, patches: [...patches] } }) + await ctx.get('loader')?.await() +} + /** * The slice of `process` {@link installFailLoud} needs — injectable so tests * exercise the handler without registering on (or exiting) the real process. diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index e19bb13c41..e105287808 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -42,6 +42,16 @@ export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml' export interface DshBundleManifest { /** The patch layer this bundle exports, relative to its package root. */ patch: string + /** + * Id of the row in that patch which must run before every other row of the + * composition — the app's entrypoint. + * + * An entrypoint resolves what the rest of the tree needs in order to be + * configured at all (the command line an app was invoked with), and provides + * it as a service. The boot mounts entrypoints alone first, so by the time + * any other row's config is resolved, `ctx.get('<service>')` answers. + */ + entrypoint?: string } /** The profile half of the `dsh` manifest section: what a profile directory composes. */ @@ -79,6 +89,37 @@ export interface ProfileLayer { patchPath: string /** The parsed patch list. */ patches: PatchOptions[] + /** Row id this bundle declares as its entrypoint, when it has one. */ + entrypoint?: string +} + +/** + * The composition's entrypoint row ids, in bundle order. + * @param binName - the diagnostic prefix on the thrown error. + * @param profile - the loaded profile. + * @param rows - the composed rows, so an entrypoint a later layer removed or + * disabled is not mounted (the one-shot bundle takes over the web one this way). + * @returns the row ids to mount before the rest of the tree. + * @throws when a bundle declares an entrypoint its own patch never inserts. + */ +export function resolveEntrypoints( + binName: string, + profile: Profile, + rows: readonly { id?: string; disabled?: boolean | null }[], +): string[] { + const entrypoints: string[] = [] + for (const layer of profile.layers) { + if (layer.entrypoint === undefined) continue + const row = rows.find(candidate => candidate.id === layer.entrypoint) + if (row === undefined) { + throw new Error( + `${binName}: bundle ${JSON.stringify(layer.packageName)} declares entrypoint ${JSON.stringify(layer.entrypoint)}, ` + + 'which the composed tree has no row for', + ) + } + if (row.disabled !== true) entrypoints.push(layer.entrypoint) + } + return entrypoints } /** A loaded profile: resolved bundle layers plus the user's own patch layer. */ @@ -391,7 +432,14 @@ export function loadProfile( throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`) } const patchPath = join(packageDir, declared) - return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) } + const entrypoint = bundleManifest.dsh?.bundle?.entrypoint + return { + packageName, + packageDir, + patchPath, + patches: loadOverlayPatches(binName, patchPath), + ...entrypoint === undefined ? {} : { entrypoint }, + } }) const patchPath = join(dir, PROFILE_PATCH_FILENAME) const patches = options.userLayer !== false && existsSync(patchPath) diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index bd0294475d..48166042f0 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -17,6 +17,7 @@ import { PROFILE_TEMPLATES, readProfileManifest, resolveBundleDir, + resolveEntrypoints, resolveProfileDir, writeProfileManifest, } from '../src/index.ts' @@ -197,6 +198,37 @@ describe('loadProfile', () => { }) }) +describe('resolveEntrypoints', () => { + const profile = (layers: { packageName: string; entrypoint?: string }[]): Parameters<typeof resolveEntrypoints>[1] => ({ + name: 'p', + dir: '/p', + patchPath: '/p/cordis.patch.yml', + patches: [], + layers: layers.map(layer => ({ ...layer, packageDir: '/b', patchPath: '/b/cordis.patch.yml', patches: [] })), + }) + + it('names each bundle entrypoint in bundle order', () => { + expect(resolveEntrypoints( + 'dsh', + profile([{ packageName: 'a' }, { packageName: 'b', entrypoint: 'b-startup' }, { packageName: 'c', entrypoint: 'c-startup' }]), + [{ id: 'b-startup' }, { id: 'c-startup' }, { id: 'other' }], + )).toEqual(['b-startup', 'c-startup']) + }) + + it('skips an entrypoint a later layer disabled, which is how one app takes over another', () => { + expect(resolveEntrypoints( + 'dsh', + profile([{ packageName: 'web', entrypoint: 'web-startup' }, { packageName: 'one-shot', entrypoint: 'one-shot-startup' }]), + [{ id: 'web-startup', disabled: true }, { id: 'one-shot-startup' }], + )).toEqual(['one-shot-startup']) + }) + + it('fails loud when a bundle declares an entrypoint its patch never inserts', () => { + expect(() => resolveEntrypoints('dsh', profile([{ packageName: 'b', entrypoint: 'absent' }]), [{ id: 'other' }])) + .toThrow('declares entrypoint "absent", which the composed tree has no row for') + }) +}) + describe('composeEntries', () => { it('applies layers over an empty root and reports skipped patches', () => { const warnings: string[] = [] diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 2e67bd08f8..a55d2d246f 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -13,7 +13,9 @@ import { Context } from '@deepseek-ai/cordis' import Hmr from '@deepseek-ai/cordis-plugin-hmr' import Loader from '@deepseek-ai/cordis-plugin-loader' import Timer from '@deepseek-ai/cordis-plugin-timer' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { + applyRootPatches, boot, loadOptionalPatches, PROFILE_PATCH_FILENAME, @@ -92,23 +94,81 @@ describe('loadOptionalPatches', () => { }) }) -describe('boot with user patches', () => { - function writeTree(dir: string): string { - writeFileSync(join(dir, 'noop.mjs'), [ - 'export const name = "noop"', - 'export function apply(_ctx, config = {}) {', - ' if (config.fail) throw new Error("candidate config failed")', - '}', +function writeTree(dir: string): string { + writeFileSync(join(dir, 'noop.mjs'), [ + 'export const name = "noop"', + 'export function apply(_ctx, config = {}) {', + ' if (config.fail) throw new Error("candidate config failed")', + '}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') + return join(dir, 'cordis.yml') +} + +function entryConfig(ctx: Context, id: string): unknown { + return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config +} + +describe('applyRootPatches', () => { + it('mounts a later phase whose rows read what the first phase provided', async () => { + // The phased boot in one test: a row's `!!js` config is evaluated when the + // include applies it, so a value an earlier phase provided is what a later + // phase's rows read. + const dir = tmp() + writeFileSync(join(dir, 'provider.mjs'), [ + 'export const name = "provider"', + 'export function apply(ctx) { ctx.provide("phaseOne", { value: "resolved" }) }', '', ].join('\n')) - writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') - return join(dir, 'cordis.yml') - } + writeFileSync(join(dir, 'reader.mjs'), [ + 'export const name = "reader"', + 'export const inject = ["phaseOne"]', + 'export function apply() {}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + const composition: PatchOptions[] = [{ + insert: [ + { id: 'provider', name: './provider.mjs' }, + { + id: 'reader', + name: './reader.mjs', + inject: ['phaseOne'], + config: { value: { __jsExpr: "ctx.get('phaseOne')?.value ?? 'fallback'" } }, + }, + ], + }] + const ctx = await boot(NAME, join(dir, 'cordis.yml'), [ + ...structuredClone(composition), + { id: 'reader', disabled: true }, + ]) + try { + // Phase one leaves the reader disabled, so the plugin never ran. + const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader') + expect(reader?.fiber).toBeUndefined() + await applyRootPatches(ctx, structuredClone(composition)) + // Phase two evaluates its config expression against the provided value. + expect(entryConfig(ctx, 'reader')).toEqual({ value: 'resolved' }) + } finally { + await ctx.fiber.dispose() + } + }) - function entryConfig(ctx: Context, id: string): unknown { - return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config - } + it('does nothing on a tree that was already disposed', async () => { + const dir = tmp() + const ctx = await boot(NAME, writeTree(dir)) + await ctx.fiber.dispose() + await expect(applyRootPatches(ctx, [])).resolves.toBeUndefined() + }) + it('fails loud when the tree was booted without the root include', async () => { + const ctx = new Context() + await expect(applyRootPatches(ctx, [])).rejects.toThrow('requires the root Include entry') + }) +}) + +describe('boot with user patches', () => { it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => { const dir = tmp() const userDir = tmp() diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index f1e5a30951..7c986b0d26 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/cmdline/README.md -README.md: 3d7aa7fd58c7e542ac0c733eb0794436cb0fc42d -README.zh.md: d6eb191e1c0c8136a613d5e9fe29bb66420139ac +README.md: acdc3a310f0062f1b27dbd74d20b81e1a8198bca +README.zh.md: 365a2c7f3cdf5710ce7e3abe76f009dc1ba4217f diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 3d7aa7fd58..acdc3a310f 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -4,57 +4,59 @@ English | [中文](README.zh.md) The command line a dsh launcher hands to the app it boots. The launcher parses only its own flags (`--profile`, `--patch`, the config dumps) and hands **everything after them** to the tree verbatim, so an app owns its flag family, its `--help` text, and its parse errors instead of the launcher knowing them. -## The three launcher values +## The launcher values A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which provides: - `ctx.cmdlineArgs` — the invocation's inner arguments. `get()` is the whole interface, and it returns a snapshot: `dsh --profile tui --resume abc` yields `['--resume', 'abc']`. - `ctx.appExit` — a bounded process-exit request, wired to the launcher's shutdown controller. -- `ctx.appPatches` — where a startup row records its decisions, for a launcher that recomposes its tree. Omitted by a host that never does. +- `ctx.appReady` — settles when the launcher has finished mounting, for a row that publishes readiness (a URL line a supervisor waits for). An embedding host with no command line provides an empty list; that is the honest answer, not a missing value. -## Startup rows and the services their rows wait for +## Entrypoints, and the service their app reads -An app reads those arguments from a **startup row** — a plugin that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`: +An app reads those arguments from its **entrypoint row** — a plugin that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] -export function apply(ctx: Context): Promise<void> { - return runStartup(ctx, 'webStartup', webCommand(), planWebStartup) +export function apply(ctx: Context): void { + runStartup(ctx, 'webStartup', webCommand(), planWebStartup) } ``` -Every row the app configures from flags injects that startup service in the bundle patch: +The bundle's `package.json` names that row, which is what makes the boot mount it before everything else: + +```json +{ "dsh": { "bundle": { "patch": "./cordis.patch.yml", "entrypoint": "web-startup" } } } +``` + +Every row the app configures from flags then reads what the entrypoint resolved, naming the key it takes and the value it falls back to: ```yaml - id: webserver name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: - host: 127.0.0.1 - port: 3080 + host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1' + port: !!js ctx.get('webStartup')?.port ?? 3080 ``` -`runStartup` parses the arguments, asks `plan` what each waiting row's values should be, applies them, and provides the startup service, which is what lets those rows start. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, disables the waiting rows, and requests exit — the app never starts, and the settlement audit sees a tree that was asked not to start it. +`runStartup` parses the arguments, asks `plan` for the values, and provides them as the service. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text and requests exit — nothing is provided, and the rest of the composition never mounts. -`plan` receives every waiting row's **composed** options, so a decision reads what the bundle patches and the user's own layers agreed on before overriding it; `overrideConfig(row, { port })` replaces exactly the named keys. A row absent from the plan starts on its composed values, and planning a change for a row also enables it. +`plan` receives the options of every row that injects the service, for a value that has to take the composition into account: the `/api` fence authorities are the shipped example, since a bind the composition configured decides whether LAN literals are derived at all. -A row whose required config the startup **supplies** rather than overrides must ship `disabled: true`, because a waiting row's config is validated when its fiber is created — before the startup service arrives — and a missing required key fails the boot there. The one-shot runner's `task` is the shipped example. A row shipped disabled for another reason is turned on the same way: `dsh web --dev` plans `{ disabled: false }` for the HMR receiver. +### Why the boot has phases -The decisions also reach the launcher through `ctx.appPatches`, which is what keeps them alive across a recomposition: without it, a user editing a live patch file would rebuild every row from its composed options and silently move a server started on `--port 8080` back to the composed port. +A row's config expressions are evaluated when the include applies it, and a strict `ctx.get` only answers for a service whose providing fiber is already active. A composition therefore mounts in two passes: the entrypoints alone, then everything else — which is exactly what the manifest declaration buys. The rows of a later pass read live values, a `--help` exits before the second pass exists, and a user editing a live patch file re-runs that pass against services that are still up, so a flag cannot be silently reset. -### Why a changed row is recycled - -A waiting row's config is resolved when the Loader creates its fiber, which happens while the row is still waiting. Writing a new config onto that fiber never reaches the plugin, so each changed row is disabled and re-enabled, which drops the stale fiber and resolves the config again. A row whose own mount is still in flight is allowed to settle first, so the disable has a fiber to dispose instead of racing one into existence. - -Recycling deliberately leaves `inject` alone. Updating a row's `inject` restarts it from its unwrapped callback, which loses the plugin's own static injections — a row that declares `inject = ['httpServer', 'apiProxy']` would come back unable to read either. +`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). Call it from a row that mounts beside the one being enabled, not from an entrypoint: a row enabled in the first pass would wait for services the second pass has yet to mount. ### One command line, one owner -A composition has exactly one command-line owner. An app that layers over another one disables the underlying startup row and names both startup services, so the rows it absorbed start on their composed values — [`dsh-headless`](../../bundle/headless/README.md) does this over [`dsh-web-app`](../../bundle/web-app/README.md). +A composition has exactly one command-line owner. An app that layers over another one disables the underlying entrypoint row and names both services, so the rows it absorbed start on the values their own fallbacks name — [`dsh-headless`](../../bundle/headless/README.md) does this over [`dsh-web-app`](../../bundle/web-app/README.md). An out-of-tree plugin brings its own commander copy, so commander's control-flow errors are detected structurally rather than by class identity; an identity check would rethrow a printed help as a fatal load failure. @@ -69,4 +71,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Launcher flags must precede app arguments.** The split is positional: the first token the launcher does not recognize starts the inner arguments, so `--patch` placed after an app flag belongs to the app. The launcher's parser consumes one `--`, so an app argument that must survive as a literal `--` needs `-- --`. -- **A startup service has no declared owner.** The rows name it and a startup row provides it; nothing links the two statically, so a bundle that ships waiting rows without its startup row fails at settlement (pending entries naming the service) rather than at load. +- **A startup service has no declared owner.** The rows name it and an entrypoint provides it; nothing links the two statically, so a bundle that ships reading rows without its entrypoint fails at settlement (pending entries naming the service) rather than at load. +- **A user patch that replaces a row's whole `config` drops its expressions.** A flag beats the value written beside it, not a literal a user wrote in place of the expression; keeping the expression is what keeps the flag winning. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index d6eb191e1c..365a2c7f3c 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -4,57 +4,59 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属于自己的 flag(`--profile`、`--patch`、配置 dump),并把**其后的一切**原样交给配置树,因此 flag 家族、`--help` 文本和解析错误都由应用自己持有,启动器不必知道它们。 -## 启动器提供的三个值 +## 启动器提供的值 启动器在任何配置树条目挂载之前调用 `provideCmdline(ctx, host)`,它提供: - `ctx.cmdlineArgs`:本次调用的内层参数。`get()` 就是它的全部接口,返回一份快照:`dsh --profile tui --resume abc` 得到 `['--resume', 'abc']`。 - `ctx.appExit`:一个有边界的进程退出请求,接到启动器的关停控制器上。 -- `ctx.appPatches`:启动行记录自身决策的去处,面向会重新组合自己配置树的启动器。从不重新组合的宿主不提供它。 +- `ctx.appReady`:在启动器挂载完毕时结算,供需要公布就绪信号的行使用(例如督程会等待的 URL 行)。 没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 -## 启动行,以及各行所等待的服务 +## 入口点,以及它的应用所读取的服务 -应用从**启动行**读取这些参数:启动行是一个注入 `cmdlineArgs` 并调用 `runStartup(ctx, service, program, plan)` 的插件: +应用从自己的**入口点行**读取这些参数:入口点行是一个注入 `cmdlineArgs` 并调用 `runStartup(ctx, service, program, plan)` 的插件: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] -export function apply(ctx: Context): Promise<void> { - return runStartup(ctx, 'webStartup', webCommand(), planWebStartup) +export function apply(ctx: Context): void { + runStartup(ctx, 'webStartup', webCommand(), planWebStartup) } ``` -应用用 flag 配置的每一行,都在组合包 patch 中注入那个启动服务: +组合包的 `package.json` 点名那一行,这正是 boot 先于其他一切挂载它的依据: + +```json +{ "dsh": { "bundle": { "patch": "./cordis.patch.yml", "entrypoint": "web-startup" } } } +``` + +应用用 flag 配置的每一行随后读取入口点解析出的取值,各自点名自己取用的键,以及回退时使用的值: ```yaml - id: webserver name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: - host: 127.0.0.1 - port: 3080 + host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1' + port: !!js ctx.get('webStartup')?.port ?? 3080 ``` -`runStartup` 解析参数,向 `plan` 询问每个等待中的行应有的取值,应用这些取值,然后提供启动服务,正是这一步让这些行得以启动。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本,禁用等待中的行并请求退出:应用从不启动,结算审计看到的是一棵被要求不要启动它的树。 +`runStartup` 解析参数,向 `plan` 索取取值,并把它们作为服务提供出去。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本并请求退出:什么也不会被提供,组合的其余部分也从不挂载。 -`plan` 收到的是每个等待中的行**组合后**的选项,因此决策在覆盖之前能读到组合包 patch 与用户自己那几层达成的结果;`overrideConfig(row, { port })` 只替换点名的那些配置键。plan 中未出现的行按组合后的取值启动;而为某一行 plan 了改动,也会顺带启用它。 +`plan` 收到的是所有注入该服务的行的选项,用于那些必须顾及组合本身的取值:随附的例子是 `/api` 栅栏 authority,因为组合所配置的 bind 决定了是否要派生 LAN 字面量。 -必填配置由启动流程**供给**而非覆盖的行,必须以 `disabled: true` 交付,因为等待中的行的配置在其 fiber 创建时就会被校验(此时启动服务尚未到达),缺少一个必填键会在那里就让 boot 失败。一次性运行器的 `task` 就是随附的例子。因其他原因以禁用状态交付的行也以同样方式打开:`dsh web --dev` 为 HMR(热模块替换)接收方 plan 了一个 `{ disabled: false }`。 +### 为什么 boot 分阶段 -这些决策同时经 `ctx.appPatches` 到达启动器,正是这一点让它们在一次重新组合中存活下来:没有它,用户编辑一个活动的 patch 文件就会把每一行都从其组合后的选项重建出来,并悄悄把一台以 `--port 8080` 启动的服务器挪回组合后的端口。 +行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答。因此一套组合分两趟挂载:先是各入口点,然后才是其余部分——这正是 manifest(元数据清单)声明所换来的东西。后一趟的行读到的是活的取值,`--help` 在第二趟存在之前就退出,而用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新运行,因此 flag 不会被悄悄重置。 -### 为什么改动过的行要回收重建 - -等待中的行的配置在 Loader 创建它的 fiber 时就已解析,而这发生在该行仍在等待的时候。把新配置写到这个 fiber 上,永远到不了插件,因此每个改动过的行都会先禁用再重新启用,从而丢弃陈旧的 fiber 并重新解析配置。自身挂载仍在进行中的行会先被放行至停稳,这样禁用时才有一个 fiber 可供 dispose(资源释放),而不是与一个正在诞生的 fiber 抢跑。 - -回收重建刻意不动 `inject`。更新一行的 `inject` 会让它从未经包装的回调重新启动,从而丢失插件自身的静态注入:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。 +`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。要从与被启用行同一趟挂载的行里调用它,而不是从入口点:在第一趟被启用的行会去等待第二趟才挂载的服务。 ### 一条命令行,一个所有者 -一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的启动行,并同时点名两个启动服务,使它吸收过来的行按组合后的取值启动:[`dsh-headless`](../../bundle/headless/README.md) 相对 [`dsh-web-app`](../../bundle/web-app/README.md) 就是这么做的。 +一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的入口点行,并同时点名两个服务,使它吸收过来的行按各自回退值启动:[`dsh-headless`](../../bundle/headless/README.md) 相对 [`dsh-web-app`](../../bundle/web-app/README.md) 就是这么做的。 树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。 @@ -69,4 +71,5 @@ export function apply(ctx: Context): Promise<void> { ## 已知限制与延期工作 - **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。 -- **启动服务没有声明所有者**:各行点名它,由启动行提供它;两者之间没有静态关联,因此交付了等待中的行却缺少对应启动行的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。 +- **启动服务没有声明所有者**:各行点名它,由入口点提供它;两者之间没有静态关联,因此交付了读取行却缺少对应入口点的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。 +- **用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉**:flag 胜过的是表达式旁写着的那个值,而不是用户用字面量替换掉表达式之后的结果;保留表达式才能保留 flag 的优先级。 diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index 3dce71079e..43c3b9ec58 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -8,17 +8,21 @@ * text, and its parse errors instead of the launcher knowing them. * * An app consumes those arguments from a **startup plugin**: a row that - * injects `cmdlineArgs` and calls {@link runStartup}. Every row the app - * configures from flags declares `inject: [<startup service>]` in the bundle - * patch and therefore waits until the startup plugin provides that service; - * `--help` prints, disables exactly those rows, and requests exit, so the app - * never starts. + * injects `cmdlineArgs` and calls {@link runStartup}. What that plugin resolves + * becomes its own service, and the rows it configures read the values from + * there — `port: !!js ctx.get('webStartup')?.port ?? 3080` — so a flag beats + * the value written beside it. Nothing is handed back to the launcher. + * + * Those rows ship `disabled: true`, because a row's config is resolved when the + * Loader creates its fiber and a strict `ctx.get` only sees a service whose + * providing fiber is already active. The startup plugin enables them once its + * own fiber is active, and keeps them enabled when a recomposition of the tree + * puts them back. * @module @deepseek-ai/dsh-cmdline */ import type { Command } from 'commander' import type { Context } from 'cordis' -import type { PatchOptions } from '@cordisjs/plugin-include' import type { Entry, EntryOptions } from '@cordisjs/plugin-loader' // Empty type import carries the loader Context merge used to walk the tree. import type {} from '@cordisjs/plugin-loader' @@ -45,61 +49,47 @@ export interface AppExit { (code: number): void } -/** - * The launcher's own patch layer, above every layer a user can edit. - * - * A startup row's decisions are facts about this invocation, so they must - * outlive a recomposition of the tree: a launcher that re-applies its patch - * stack when the user edits a live patch file rebuilds every row from its - * composed options, which would otherwise silently reset a flag-configured - * row (a browser served on `--port 8080` would move back to the composed - * port on an unrelated edit). - */ -export interface AppPatches { - /** - * Record patches the launcher must keep applying on every later composition. - * @param patches - the startup row's decisions, as patches over the composed rows. - */ - contribute(patches: readonly PatchOptions[]): void -} - declare module 'cordis' { interface Context { /** The invocation's inner arguments; provided by a launcher before the tree mounts. */ cmdlineArgs?: CmdlineArgs /** Bounded process-exit request; provided by a launcher before the tree mounts. */ appExit?: AppExit - /** The launcher's own patch layer; provided by a launcher that recomposes its tree. */ - appPatches?: AppPatches + /** Settles when the launcher has mounted the whole composition; see {@link CmdlineHost.ready}. */ + appReady?: Promise<void> } } -/** The launcher facts an app's startup row needs. */ +/** The launcher facts an app needs. */ export interface CmdlineHost { /** The invocation's inner arguments, in argv order. */ args: readonly string[] /** Bounded process-exit request. */ exit: AppExit /** - * Sink for startup decisions a later recomposition must keep. A launcher - * that never recomposes its tree (a one-shot embedding host) omits it. + * Settles when the launcher has finished mounting, which a row that + * publishes readiness (a URL line a supervisor waits for) must await. + * + * A boot mounts in phases, so Loader settlement no longer means the whole + * composition is up: a row mounted in a later phase can observe a settled + * tree while rows beside it have yet to mount, or while the phase that + * mounted it is already rolling back. Rejects with the boot failure. */ - contribute?: AppPatches['contribute'] + ready?: Promise<void> } /** - * Provide the command line, the exit request, and the patch sink on a host - * context before any tree entry mounts. These are launcher facts, not config: - * an embedding host with no command line provides an empty argument list. + * Provide the command line and the exit request on a host context before any + * tree entry mounts. Both are launcher facts, not config: an embedding host + * with no command line provides an empty argument list. * @param ctx - the host context the tree will mount under. - * @param host - the invocation's arguments, exit request, and optional patch sink. + * @param host - the invocation's arguments and its exit request. */ export function provideCmdline(ctx: Context, host: CmdlineHost): void { const snapshot = [...host.args] ctx.provide('cmdlineArgs', { get: () => snapshot }) ctx.provide('appExit', host.exit) - const contribute = host.contribute - if (contribute !== undefined) ctx.provide('appPatches', { contribute }) + if (host.ready !== undefined) ctx.provide('appReady', host.ready) } /** The process streams commander output is written to; production writes to the process. */ @@ -109,62 +99,55 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w } /** - * What a startup plugin changes on one waiting row. A row with a change is - * re-enabled as part of applying it; `{ disabled: true }` keeps it off (and - * `{ disabled: false }` is how a row a bundle ships disabled gets turned on). - */ -export type RowChange = Omit<Partial<EntryOptions>, 'id' | 'inject'> - -/** - * Decide this invocation's changes for the rows waiting on an app's startup - * service. + * Resolve this invocation into the values the app's rows read. * - * Runs after a successful parse, with every waiting row's composed options - * (bundle layers, the user's layers, and any `--patch` overlay already - * applied), so a decision can read what the composition agreed on before - * overriding it. Call `program.error(...)` to reject the invocation with a - * usage message instead of throwing. + * Runs after a successful parse, with the waiting rows' composed options + * available for a value that has to take the composition into account (the + * `/api` fence authorities are the shipped example). Call `program.error(...)` + * to reject the invocation with a usage message instead of throwing. * @param program - the parsed commander program. * @param rows - the waiting rows' composed options, in tree order. - * @returns row id → the changes for that row; ids absent from the map start unchanged. + * @returns the service value the app's rows read; `undefined` keys let a row's + * own fallback stand. */ -export type StartupPlan = (program: Command, rows: readonly EntryOptions[]) => Map<string, RowChange> +export type StartupPlan<T = unknown> = (program: Command, rows: readonly EntryOptions[]) => T /** * Run one app's startup: parse the invocation's inner arguments with the app's - * own commander program, apply the resulting changes to the waiting rows, and - * release them by providing the startup service they inject. + * own commander program, provide the resolved values as `service`, and start + * the rows that were waiting for it. * - * A waiting row's config is resolved when the Loader creates its fiber, which - * happens while the row is still waiting, so writing a new config onto that - * fiber would never reach the plugin. Each changed row is therefore recycled — - * disabled, then re-enabled with its new values — which drops the stale fiber - * and resolves the config again. Recycling deliberately leaves `inject` alone: - * an `inject` update restarts the row from its unwrapped callback and loses the - * plugin's own static injections. + * The rows read their values from the service, so nothing is written into + * their config from here: a row asks for `ctx.get('<service>')?.<key>` and + * falls back to the value written beside it, which is why a flag wins. They are + * enabled from inside an injection on the service itself, because a strict + * `ctx.get` only resolves a service whose providing fiber is already active, + * and re-enabled whenever a recomposition of the tree disables them again — a + * user editing a live patch file must not take the app down. * * Help, version, and rejected arguments are terminal for the process: the text - * is written, every waiting row is disabled so the settlement audit sees a tree - * that was asked not to start this app, and `ctx.appExit` is requested. + * is written, the service is never provided, the app's rows stay disabled, and + * `ctx.appExit` is requested. * * An app that layers over another one (the one-shot bundle rides over the web - * bundle) disables the underlying startup row and names both startup services, - * because a composition has exactly one command-line owner: the rows of the app - * it absorbed then start on their composed values. + * bundle) disables the underlying startup row and names both services, because + * a composition has exactly one command-line owner: the rows of the app it + * absorbed then start on the values their own fallbacks name. * @param ctx - plugin context carrying `cmdlineArgs`, `appExit`, and the Loader. - * @param services - the startup service name, or names, that this app's rows declare in their `inject`. + * @param services - the service name, or names, this startup row provides. * @param program - the app's commander program, with its flags and description already declared. - * @param plan - this invocation's per-row changes; omitted starts the waiting rows unchanged. - * @returns nothing once the waiting rows are released, or once the exit was requested. - * @throws when the launcher provided no command line, when a startup service is - * declared by no row, or when `plan` names a row that is not waiting. + * @param plan - this invocation's resolved values; omitted provides an empty value. + * @returns the resolved values, or `undefined` when the app asked to exit + * instead (help, version, or arguments it rejected). + * @throws when the launcher provided no command line, or when a named service + * is injected by no row. */ -export async function runStartup( +export function runStartup<T>( ctx: Context, services: string | readonly string[], program: Command, - plan: StartupPlan = () => new Map(), -): Promise<void> { + plan: StartupPlan<T> = (() => ({}) as T), +): T | undefined { const names = typeof services === 'string' ? [services] : services // Read through the global service store, not the property proxy: these are // optional host values, and a row that injects only `cmdlineArgs` may not @@ -180,75 +163,47 @@ export async function runStartup( writeOut: text => void internals.stdout.write(text), writeErr: text => void internals.stderr.write(text), }) - let decisions: Map<string, RowChange> - let rows: EntryOptions[] + let values: T try { program.parse(args.get(), { from: 'user' }) // An app can dispose the whole tree while this row is still parsing (an - // early SIGTERM, or another app exiting). There is then nothing to - // configure and nothing to release, and the checks below would blame the - // bundle for a tree that simply went away. - if (ctx.get('loader') === undefined) return - rows = waitingRows(ctx, names) - decisions = plan(program, rows) + // early SIGTERM, or another app exiting). There is then nothing to resolve + // and nothing to start, and the check below would blame the bundle for a + // tree that simply went away. + if (ctx.get('loader') === undefined) return undefined + values = plan(program, waitingRows(ctx, names)) } catch (error) { // exitOverride turns help, version, a parse error, and a plan's own // program.error() into a CommanderError; commander has already written the - // text through the output configured above. + // text through the output configured above. The app's rows ship disabled, + // so leaving them alone is what keeps the app unstarted. if (!isCommanderError(error)) throw error - for (const entry of waitingEntries(ctx, names)) await stopRow(entry) exit(error.exitCode) - return + return undefined } - const unknown = [...decisions.keys()].filter(id => !rows.some(row => row.id === id)) - if (unknown.length > 0) { - throw new Error(`${program.name()}: startup planned changes for row(s) ${unknown.join(', ')}, which inject none of ${names.join(', ')}`) - } - const contributed: PatchOptions[] = [] - for (const entry of waitingEntries(ctx, names)) { - const change = decisions.get(entry.options.id) - if (change === undefined) continue - await stopRow(entry) - await entry.update({ disabled: false, ...change }) - contributed.push({ id: entry.options.id, disabled: false, ...change }) - } - // Hand the same decisions to the launcher as patches, so a later - // recomposition of the tree (a user editing a live patch file) rebuilds - // these rows with this invocation's values instead of the composed ones. - if (contributed.length > 0) ctx.get('appPatches')?.contribute(contributed) - // The rows are ready; providing the service they inject starts them, and a - // row this invocation left disabled stays that way. - for (const service of names) ctx.provide(service, true) + for (const service of names) ctx.provide(service, values) + return values } /** - * Stop a waiting row, including one whose own mount is still in flight. + * Turn on a row this composition ships disabled, because this invocation asked + * for it (`dsh web --dev` and its client-plugin reload chain). * - * Disabling alone is not a barrier: a row whose init has not finished has no - * fiber yet, so the update returns while that init goes on to create one, and - * the re-enable would then take the config-patch path, which a still-waiting - * fiber never applies — the row would start on stale values. Letting the mount - * settle first gives the disable a fiber to dispose. A row the composition - * ships disabled has no mount to settle and is left alone. - * @param entry - the waiting row's Loader entry. + * A row cannot be inserted from inside a mounting plugin — the Loader returns a + * prefixed id it then fails to resolve — so a conditional row ships disabled + * and an entrypoint enables it. + * Call it from a row that mounts alongside the one being enabled: an + * entrypoint runs before the rest of the composition, so a row it enabled + * there would wait for services that have yet to mount. + * @param ctx - plugin context whose Loader tree carries the row. + * @param id - the row id. + * @returns nothing once the row has started. + * @throws when the composition has no row with that id. */ -async function stopRow(entry: Entry): Promise<void> { - await entry.refresh() - await entry.update({ disabled: true }) -} - -/** - * Merge flag overrides over a waiting row's composed config. - * - * A row's composed config is what the bundle patches and the user's own layers - * agreed on; a flag replaces exactly the keys it names and leaves the rest of - * that agreement intact. - * @param options - the waiting row's composed options. - * @param overrides - the values this invocation's flags decided, by config key. - * @returns the change to put in a {@link StartupPlan}'s map. - */ -export function overrideConfig(options: EntryOptions, overrides: Record<string, unknown>): RowChange { - return { config: { ...(options.config ?? {}) as Record<string, unknown>, ...overrides } } +export async function enableRow(ctx: Context, id: string): Promise<void> { + const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === id) + if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`) + await entry.update({ disabled: false }) } /** @@ -256,8 +211,8 @@ export function overrideConfig(options: EntryOptions, overrides: Record<string, * @param ctx - plugin context whose Loader tree carries the rows. * @param services - the startup service names. * @returns the waiting rows' options. - * @throws when a startup service is declared by no row, which means the bundle - * patch and its startup plugin disagree. + * @throws when a service is injected by no row, which means the bundle patch + * and its startup plugin disagree. */ function waitingRows(ctx: Context, services: readonly string[]): EntryOptions[] { for (const service of services) { @@ -276,7 +231,7 @@ function waitingRows(ctx: Context, services: readonly string[]): EntryOptions[] */ function waitingEntries(ctx: Context, services: readonly string[]): Entry[] { // Called only after runStartup established the tree is still live. - return [...ctx.loader.entries()].filter(entry => services.some(service => waitsFor(entry.options.inject, service))) + return [...ctx.loader.entries()].filter(entry => waitsForAny(entry.options.inject, services)) } /** @@ -298,14 +253,15 @@ function isCommanderError(error: unknown): error is { code: string; exitCode: nu } /** - * Whether a row's `inject` declaration names `service`. + * Whether a row's `inject` declaration names any of `services`. * @param inject - the row's `inject` value: the array form, the object form, or absent. - * @param service - the startup service name. - * @returns true when the row waits for it. + * @param services - the startup service names. + * @returns true when the row waits for one of them. */ -function waitsFor(inject: EntryOptions['inject'], service: string): boolean { +function waitsForAny(inject: EntryOptions['inject'], services: readonly string[]): boolean { if (inject === undefined || inject === null) return false // The array form lists service names; the object form maps each name to its // intercept config. Both name the service as a key of the same shape. - return Array.isArray(inject) ? inject.includes(service) : Object.hasOwn(inject, service) + const declared = Array.isArray(inject) ? inject : Object.keys(inject) + return services.some(service => declared.includes(service)) } diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index b4ea1a3624..ee405bb135 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -1,8 +1,8 @@ /** - * The launcher-to-app command line over a REAL Loader tree: a startup row parses the - * invocation's inner arguments and releases the rows waiting for it, waiting rows start - * with the resolved values, `--help` leaves the app unstarted, and a - * bundle whose patch and startup plugin disagree fails loud. + * The launcher-to-app command line over a REAL Loader tree, mounted the way a + * profile boot mounts it: the entrypoint row first, then the rest of the + * composition, whose rows read the entrypoint's values from their own config + * expressions. `--help` never reaches that second phase. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -13,12 +13,14 @@ import { Command } from 'commander' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' +import type { PatchOptions } from '@cordisjs/plugin-include' import { afterEach, describe, expect, it } from 'vitest' -import { internals, overrideConfig, provideCmdline, runStartup, type RowChange, type StartupPlan } from '../src/index.ts' +import { internals, provideCmdline, runStartup, type StartupPlan } from '../src/index.ts' /** Every value one boot of the fixture tree observed. */ interface Observed { - applied: { id: string; config: Record<string, unknown> }[] + /** Config the reading row started with; absent means it never started. */ + started?: Record<string, unknown> exits: number[] out: string } @@ -27,13 +29,8 @@ interface Observed { interface Fixture { observed: Observed ctx: Context - /** Patches the startup row handed the launcher for later compositions. */ - contributed: unknown[] } -/** Cordis FiberState.ACTIVE, mirrored because the const enum has no runtime object. */ -const FIBER_ACTIVE = 2 - const disposers: (() => Promise<void>)[] = [] afterEach(async () => { @@ -42,202 +39,145 @@ afterEach(async () => { internals.stderr = process.stderr }) -/** The fixture's flag family: one `--port` over the waiting row's composed config. */ +/** The fixture app's flag family: one `--port` its rows read from the service. */ function demoCommand(): Command { return new Command().name('demo').exitOverride().option('--port <port>', 'listen port') } -/** The fixture's plan: `--port` overrides the waiting row, absent leaves it composed. */ -const demoPlan: StartupPlan = (program, rows) => { +/** The fixture app's plan: the resolved values its rows read. */ +const demoPlan: StartupPlan<{ port?: number }> = (program) => { const port = program.opts<{ port?: string }>().port - if (port === undefined) return new Map() + if (port === undefined) return {} if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`) - const row = rows.find(candidate => candidate.id === 'waiting') - return new Map(row === undefined ? [] : [['waiting', overrideConfig(row, { port: Number(port) })]]) + return { port: Number(port) } } +/** A YAML `!!js` expression node, as the include parses one out of a patch file. */ +const expression = (source: string): unknown => ({ __jsExpr: source }) + /** - * Mount a tree with one waiting row, and — unless the caller drives startup - * itself — a startup row that calls {@link runStartup} on this package's real - * code path. + * Mount a two-row composition the way a profile boot does: the entrypoint row + * alone first, then everything. * @param args - the invocation's inner arguments. - * @param options - fixture knobs for the shapes a bundle patch can produce. + * @param plan - the app's plan; defaults to the fixture's own. * @returns the booted fixture. */ async function bootFixture( args: string[], - options: { injectObjectForm?: boolean; withoutStartupRow?: boolean; slowWaitingImport?: boolean } = {}, + plan: StartupPlan = demoPlan, + options: { withoutEntrypoint?: boolean } = {}, ): Promise<Fixture> { const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) - const observed: Observed = { applied: [], exits: [], out: '' } - writeFileSync(join(dir, 'waiting.mjs'), ` -${options.slowWaitingImport === true ? 'await new Promise(resolve => setTimeout(resolve, 30))' : ''} -export const name = 'waiting' -export function apply(ctx, config) { globalThis.__observed.applied.push({ id: 'waiting', config }) } + const observed: Observed = { exits: [], out: '' } + writeFileSync(join(dir, 'reader.mjs'), ` +export const name = 'reader' +export const inject = ['demoStartup'] +export function apply(ctx, config) { globalThis.__observed.started = config } `) // The Loader imports a row through Node's own resolver, which cannot resolve // this workspace's sources; the row delegates to the real function the test // imported through the source-plane path mapping. - writeFileSync(join(dir, 'startup.mjs'), ` -export const name = 'startup' + writeFileSync(join(dir, 'entrypoint.mjs'), ` +export const name = 'demo-startup' export const inject = ['cmdlineArgs'] export function apply(ctx) { return globalThis.__runStartup(ctx) } `) - writeFileSync(join(dir, 'cordis.yml'), [ - '- id: waiting', - ` name: ${pathToFileURL(join(dir, 'waiting.mjs')).href}`, - options.injectObjectForm === true ? ' inject: { demoStartup: null }' : ' inject: [demoStartup]', - ' config:', - ' port: 3080', - ' host: 127.0.0.1', - ...options.withoutStartupRow === true ? [] : [ - '- id: startup', - ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, - ], - '', - ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') const observing = { write: (chunk: string) => { observed.out += chunk; return true } } internals.stdout = observing internals.stderr = observing - const globals = globalThis as unknown as { __observed: Observed; __runStartup: (ctx: Context) => Promise<void> } + const globals = globalThis as unknown as { __observed: Observed; __runStartup: (ctx: Context) => void } globals.__observed = observed - globals.__runStartup = (ctx: Context) => runStartup(ctx, 'demoStartup', demoCommand(), demoPlan) + globals.__runStartup = (ctx: Context) => { runStartup(ctx, 'demoStartup', demoCommand(), plan) } - const contributed: unknown[] = [] + // The composition, exactly as a profile delivers one: include patches whose + // config carries `!!js` expressions. + const composition: PatchOptions[] = [{ + insert: [ + ...options.withoutEntrypoint === true + ? [] + : [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'entrypoint.mjs')).href }], + { + id: 'reader', + name: pathToFileURL(join(dir, 'reader.mjs')).href, + inject: ['demoStartup'], + config: { port: expression("ctx.get('demoStartup')?.port ?? 3080") }, + }, + ], + }] const ctx = new Context() await ctx.plugin(Loader) ctx.loader.builtins.include = Include - provideCmdline(ctx, { - args, - exit: code => void observed.exits.push(code), - contribute: patches => void contributed.push(...patches), + provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) + const rootConfig = { path: pathToFileURL(join(dir, 'cordis.yml')).href } + // Phase one: the entrypoint alone. + const includeId = await ctx.loader.create({ + name: 'cordis:include', + config: { ...rootConfig, patches: [...structuredClone(composition), { id: 'reader', disabled: true }] }, }) - await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) await ctx.loader.await() disposers.push(async () => { await ctx.fiber.dispose() }) - return { observed, ctx, contributed } + if (observed.exits.length === 0) { + // Phase two: the whole composition, now that the entrypoint's values answer. + await ctx.loader.resolve(includeId).update({ config: { ...rootConfig, patches: structuredClone(composition) } }) + await ctx.loader.await() + } + return { observed, ctx } } describe('runStartup', () => { - it('starts a waiting row only after the startup service arrives, with the flag value applied over its composed config', async () => { + it('lets a row read the flag value the app resolved', async () => { const { observed } = await bootFixture(['--port', '8080']) - expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) + expect(observed.started).toEqual({ port: 8080 }) expect(observed.exits).toEqual([]) }) - it('starts the waiting row unchanged when the invocation carries no flags', async () => { + it('leaves a row on the value written beside the expression when no flag names one', async () => { const { observed } = await bootFixture([]) - expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 3080, host: '127.0.0.1' } }]) + expect(observed.started).toEqual({ port: 3080 }) }) - it('applies the flag value to a row whose own mount was still in flight', async () => { - // The row has no fiber yet when startup disables it, so the disable is not - // a barrier: the in-flight mount still produces one. Without disposing - // that late fiber, the row would start on its composed port. - const { observed } = await bootFixture(['--port', '8080'], { slowWaitingImport: true }) - expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) - }) - - it('starts a row that injects the startup service in the intercept-map form of inject', async () => { - const { observed } = await bootFixture(['--port', '8080'], { injectObjectForm: true }) - expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) - }) - - it('prints the app help, leaves the app unstarted, and requests exit 0', async () => { + it('prints the app help, starts no reading row, and requests exit 0', async () => { const { observed } = await bootFixture(['--help']) expect(observed.out).toContain('Usage: demo') - expect(observed.applied).toEqual([]) + expect(observed.started).toBeUndefined() expect(observed.exits).toEqual([0]) }) it('rejects the invocation from the plan without starting the app', async () => { const { observed } = await bootFixture(['--port', 'abc']) expect(observed.out).toContain('--port must be a number') - expect(observed.applied).toEqual([]) + expect(observed.started).toBeUndefined() expect(observed.exits).toEqual([1]) }) -}) - -describe('startup-service lifetime', () => { - it('unloads the waiting rows when the startup row is disposed, and reopens on a fresh run', async () => { - // The startup service is an effect of the startup row: HMR restarting that - // row must take its app down with it, then bring it back. - const { ctx, observed } = await bootFixture(['--port', '8080']) - const startup = [...ctx.loader.entries()].find(entry => entry.options.id === 'startup') - const waiting = [...ctx.loader.entries()].find(entry => entry.options.id === 'waiting') - expect(waiting?.fiber?.state).toBe(FIBER_ACTIVE) - await startup?.update({ disabled: true }) - expect(waiting?.fiber?.state).not.toBe(FIBER_ACTIVE) - await startup?.update({ disabled: false }) - await ctx.loader.await() - expect(waiting?.fiber?.state).toBe(FIBER_ACTIVE) - // The second run re-resolved the same arguments, so the row is back on the - // flag value rather than the composed one. - expect(observed.applied.at(-1)).toEqual({ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }) - }) -}) - -describe('runStartup rejects a bundle that disagrees with its own patch', () => { - it('fails when no row declares the startup service it provides', async () => { - // The patch and its startup plugin disagree; a silent no-op would leave - // the app's rows waiting forever with no explanation. - const { ctx } = await bootFixture([], { withoutStartupRow: true }) - await expect(runStartup(ctx, 'absentStartup', demoCommand(), demoPlan)) - .rejects.toThrow('absentStartup: no row injects this startup service') - }) - - it('fails when the plan names a row that is not waiting', async () => { - const { ctx, observed } = await bootFixture([], { withoutStartupRow: true }) - const plan: StartupPlan = () => new Map<string, RowChange>([['not-waiting', {}]]) - await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)) - .rejects.toThrow('startup planned changes for row(s) not-waiting') - expect(observed.applied).toEqual([]) - }) it('rethrows a plan failure that is not commander asking to exit', async () => { - const { ctx, observed } = await bootFixture([], { withoutStartupRow: true }) + const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) const plan: StartupPlan = () => { throw new Error('plan exploded') } - await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)).rejects.toThrow('plan exploded') - expect(observed.exits).toEqual([]) + expect(() => { runStartup(ctx, 'demoStartup', demoCommand(), plan) }).toThrow('plan exploded') }) it('rethrows a thrown value that is not an object at all', async () => { - const { ctx } = await bootFixture([], { withoutStartupRow: true }) + const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) const plan: StartupPlan = () => { const thrown: unknown = 'plan threw a string' throw thrown } - await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)).rejects.toThrow('plan threw a string') - }) -}) - -describe('the launcher patch layer', () => { - it('hands the startup row\'s decisions to the launcher as patches', async () => { - const { contributed } = await bootFixture(['--port', '8080']) - // The same decisions the rows started with: a launcher that recomposes its - // tree re-applies these, so an unrelated user edit cannot reset the port. - expect(contributed).toEqual([ - { id: 'waiting', disabled: false, config: { port: 8080, host: '127.0.0.1' } }, - ]) + expect(() => { runStartup(ctx, 'demoStartup', demoCommand(), plan) }).toThrow('plan threw a string') }) - it('contributes nothing when the invocation decided nothing', async () => { - const { contributed } = await bootFixture([]) - expect(contributed).toEqual([]) - }) -}) - -describe('an app with nothing to decide', () => { - it('starts every waiting row unchanged when it declares no plan', async () => { - const { ctx, observed } = await bootFixture([], { withoutStartupRow: true }) - // The list form of the service argument, which an app layering over - // another one uses to absorb that app's startup service. - await runStartup(ctx, ['demoStartup'], demoCommand()) - expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 3080, host: '127.0.0.1' } }]) + it('fails loud when no row injects the service the app provides', async () => { + // The bundle patch and its entrypoint disagree; a silent no-op would leave + // every row of the app on its fallbacks with no explanation. + const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) + expect(() => { runStartup(ctx, 'absentStartup', demoCommand()) }) + .toThrow('absentStartup: no row injects this startup service') }) - it('overrides a row that carries no composed config', () => { - expect(overrideConfig({ id: 'row', name: 'plugin' }, { port: 8080 })).toEqual({ config: { port: 8080 } }) + it('provides an empty value when the app declares no plan', async () => { + const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) + runStartup(ctx, 'demoStartup', demoCommand()) + expect(ctx.get('demoStartup')).toEqual({}) }) }) @@ -250,19 +190,19 @@ describe('provideCmdline', () => { expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) }) - it('fails loud when a startup row runs without the launcher values', async () => { + it('fails loud when an entrypoint runs without the launcher values', () => { const ctx = new Context() - await expect(runStartup(ctx, 'demoStartup', demoCommand())) - .rejects.toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit') + expect(() => { runStartup(ctx, 'demoStartup', demoCommand()) }) + .toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit') }) - it('opens nothing, and blames nobody, when the tree was disposed while startup was parsing', async () => { - // An early SIGTERM disposes the Loader mid-parse. There is nothing left to - // open, and the bundle did nothing wrong. + it('resolves nothing when the tree was disposed while the entrypoint parsed', () => { + // An early SIGTERM takes the Loader with it; there is nothing left to + // configure, and the bundle did nothing wrong. const exits: number[] = [] const ctx = new Context() provideCmdline(ctx, { args: [], exit: code => void exits.push(code) }) - await expect(runStartup(ctx, 'demoStartup', demoCommand())).resolves.toBeUndefined() + expect(() => { runStartup(ctx, 'demoStartup', demoCommand()) }).not.toThrow() expect(exits).toEqual([]) }) }) diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 6904931acc..eb8a2289cd 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -26,9 +26,10 @@ - id: headless-startup name: '@deepseek-ai/dsh-headless/startup' - # Shipped off, not merely waiting: the runner's schema requires the task. - # The startup row enables it with the task after parsing this app's argv. + # Reads its task from the headlessStartup service after the startup row + # resolves this app's command line. - id: headless-runner name: '@deepseek-ai/dsh-headless' inject: [headlessStartup] - disabled: true + config: + task: !!js ctx.get('headlessStartup')?.task diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 5216aa3048..5d2af07463 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -33,7 +33,8 @@ "license": "BSD-3-Clause", "dsh": { "bundle": { - "patch": "./cordis.patch.yml" + "patch": "./cordis.patch.yml", + "entrypoint": "headless-startup" } }, "dependencies": { diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index eb9907a6b7..0f613aae08 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -15,7 +15,7 @@ import { Command } from 'commander' import type { Context } from 'cordis' import type { EntryOptions } from '@cordisjs/plugin-loader' -import { overrideConfig, runStartup, type RowChange } from '@deepseek-ai/dsh-cmdline' +import { runStartup } from '@deepseek-ai/dsh-cmdline' import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup' /** Stable Cordis plugin name. */ @@ -24,12 +24,18 @@ export const name = 'headless-startup' /** Services required before the task can be resolved. */ export const inject = ['cmdlineArgs'] -/** The startup service the one-shot runner row injects. */ +/** The service this row provides and the one-shot runner row reads. */ export const HEADLESS_STARTUP_SERVICE = 'headlessStartup' -/** The runner row this app configures. */ +/** The row that runs the task, and the only reason this app has a command line. */ const RUNNER_ROW_ID = 'headless-runner' +/** What the runner row reads from {@link HEADLESS_STARTUP_SERVICE}. */ +export interface HeadlessStartupValues { + /** The task text this invocation asked for. */ + task: string +} + /** * This app's command: the task positional, its description, and its help text. * @returns a fresh program, so one process can parse more than once (tests). @@ -49,22 +55,25 @@ Examples: /** * Turn the parsed command line into the runner row's task. * @param program - the parsed headless command. - * @param rows - the waiting rows' composed options, in tree order. - * @returns row id → changes. + * @param rows - the rows waiting on this app's service, in tree order. + * @returns the runner row's service value. + * @throws when the composition has no runner row, which would otherwise accept + * a task and silently run nothing. */ -function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): Map<string, RowChange> { +function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): HeadlessStartupValues { const task = program.args.join(' ') if (task === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') - const runner = rows.find(row => row.id === RUNNER_ROW_ID) - if (runner === undefined) throw new Error(`headless-startup: the composition has no waiting "${RUNNER_ROW_ID}" row to run the task`) - return new Map([[RUNNER_ROW_ID, overrideConfig(runner, { task })]]) + if (!rows.some(row => row.id === RUNNER_ROW_ID)) { + throw new Error(`headless-startup: the composition has no waiting "${RUNNER_ROW_ID}" row to run the task`) + } + return { task } } /** - * Resolve the task and start the rows waiting for it. + * Resolve the task and start the runner that reads it. * @param ctx - plugin context carrying the command line and the Loader. - * @returns nothing once the runner is released, or once `--help` or a missing task requested exit. + * @returns nothing once the runner is started, or once `--help` or a missing task requested exit. */ -export function apply(ctx: Context): Promise<void> { - return runStartup(ctx, [HEADLESS_STARTUP_SERVICE, WEB_STARTUP_SERVICE], headlessCommand(), planHeadlessStartup) +export function apply(ctx: Context): void { + runStartup(ctx, [HEADLESS_STARTUP_SERVICE, WEB_STARTUP_SERVICE], headlessCommand(), planHeadlessStartup) } diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 1b4d6f1430..fc908306e3 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -1,7 +1,8 @@ /** - * The one-shot app's startup row over a REAL Loader tree: the task - * positional reaches the runner row, a missing task is a usage error, and the - * web startup service this app absorbs releases its rows on the composed values. + * The one-shot app's entrypoint row over a REAL Loader tree: the task + * positional becomes the value the runner row reads, a missing task is a usage + * error, and the web service this app absorbs is provided too, so the web rows + * it rides over resolve on their own fallbacks. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -9,21 +10,17 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { Context } from 'cordis' -import z from 'schemastery' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup' import { afterEach, describe, expect, it } from 'vitest' -import { apply, HEADLESS_STARTUP_SERVICE } from '../src/startup.ts' +import { apply, HEADLESS_STARTUP_SERVICE, type HeadlessStartupValues } from '../src/startup.ts' /** What one boot of the fixture tree observed. */ interface Observed { - started: Record<string, Record<string, unknown>> exits: number[] out: string - /** Patches the startup row handed the launcher for later compositions. */ - contributed: unknown[] } const disposers: (() => Promise<void>)[] = [] @@ -35,112 +32,90 @@ afterEach(async () => { }) /** - * Boot the real headless startup row over stand-ins for the runner row and one - * web row it absorbs. + * Mount the real entrypoint row over stand-ins for the runner row and one web + * row this app absorbs, the way a profile mounts phase one. * @param args - the invocation's inner arguments. - * @returns what the boot observed. + * @param options - fixture knobs for the shapes a composition can take. + * @returns the resolved service values (absent when the app requested exit) and what the boot observed. */ -async function bootStartup(args: string[], options: { withoutRunner?: boolean } = {}): Promise<Observed> { +async function bootStartup( + args: string[], + options: { withoutRunner?: boolean } = {}, +): Promise<{ task: HeadlessStartupValues | undefined; web: unknown; observed: Observed }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-')) - const observed: Observed = { started: {}, exits: [], out: '', contributed: [] } - // The runner's real schema requires the task, which is exactly what makes a - // waiting-but-enabled row fail at fiber creation; the stand-in keeps that. - writeFileSync(join(dir, 'row.mjs'), ` -export const Config = globalThis.__headlessRunnerConfigSchema -export function apply(ctx, config) { globalThis.__headlessStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } -`) - writeFileSync(join(dir, 'plain-row.mjs'), ` -export function apply(ctx, config) { globalThis.__headlessStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } -`) + const observed: Observed = { exits: [], out: '' } + writeFileSync(join(dir, 'row.mjs'), 'export function apply() {}\n') // The Loader imports a row through Node's own resolver, which cannot resolve // this workspace's sources; the row delegates to the real plugin the test // imported through the source-plane path mapping. - writeFileSync(join(dir, 'startup-row.mjs'), ` + writeFileSync(join(dir, 'entrypoint.mjs'), ` export const name = 'headless-startup' export const inject = ['cmdlineArgs'] export const apply = ctx => globalThis.__headlessStartupApply(ctx) `) const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href - const plainRowUrl = pathToFileURL(join(dir, 'plain-row.mjs')).href writeFileSync(join(dir, 'cordis.yml'), [ - // A composition that lost the runner still injects the startup service, so - // the startup row reaches its own row check rather than the generic one. + // A composition that lost the runner still injects the service, so the + // entrypoint reaches its own row check rather than the generic one. options.withoutRunner === true ? '- id: displaced-runner' : '- id: headless-runner', ` name: ${rowUrl}`, ` inject: [${HEADLESS_STARTUP_SERVICE}]`, - // Shipped off, like the bundle patch: the schema below requires the task, - // which only the startup row can supply. ' disabled: true', '- id: webserver', - ` name: ${plainRowUrl}`, + ` name: ${rowUrl}`, ` inject: [${WEB_STARTUP_SERVICE}]`, - ' config:', - ' port: 0', + ' disabled: true', '- id: headless-startup', - ` name: ${pathToFileURL(join(dir, 'startup-row.mjs')).href}`, + ` name: ${pathToFileURL(join(dir, 'entrypoint.mjs')).href}`, '', ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } internals.stdout = observing internals.stderr = observing - const globals = globalThis as unknown as { - __headlessStartupObserved: Observed - __headlessStartupApply: typeof apply - __headlessRunnerConfigSchema: unknown - } - globals.__headlessStartupObserved = observed - globals.__headlessStartupApply = apply - globals.__headlessRunnerConfigSchema = z.object({ task: z.string().required() }) + ;(globalThis as unknown as { __headlessStartupApply: typeof apply }).__headlessStartupApply = apply const ctx = new Context() await ctx.plugin(Loader) ctx.loader.builtins.include = Include - provideCmdline(ctx, { - args, - exit: code => void observed.exits.push(code), - contribute: patches => void observed.contributed.push(...patches), - }) + provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) await ctx.loader.await() disposers.push(async () => { await ctx.fiber.dispose() }) - return observed + return { + task: ctx.get(HEADLESS_STARTUP_SERVICE) as HeadlessStartupValues | undefined, + web: ctx.get(WEB_STARTUP_SERVICE), + observed, + } } describe('headless startup', () => { - it('joins the task positional and starts the runner with it', async () => { - const observed = await bootStartup(['run', 'the', 'tests']) - expect(observed.started['headless-runner']).toEqual({ task: 'run the tests' }) + it('joins the task positional into the value the runner reads', async () => { + const { task, observed } = await bootStartup(['run', 'the', 'tests']) + expect(task).toEqual({ task: 'run the tests' }) expect(observed.exits).toEqual([]) }) - it('hands the task to the launcher as a patch, so a recomposition keeps it', async () => { - const observed = await bootStartup(['run', 'the', 'tests']) - expect(observed.contributed).toEqual([ - { id: 'headless-runner', disabled: false, config: { task: 'run the tests' } }, - ]) - }) - - it('starts the web rows it absorbed on the composed one-shot values', async () => { - const observed = await bootStartup(['task']) - expect(observed.started.webserver).toEqual({ port: 0 }) + it('provides the web service it absorbed, so those rows resolve on their own fallbacks', async () => { + const { web } = await bootStartup(['task']) + expect(web).toEqual({ task: 'task' }) }) it('rejects an invocation with no task instead of failing inside the runner schema', async () => { - const observed = await bootStartup([]) + const { task, observed } = await bootStartup([]) expect(observed.out).toContain('a task is required') - expect(observed.started).toEqual({}) + expect(task).toBeUndefined() expect(observed.exits).toEqual([1]) }) + it('prints its own help and resolves nothing', async () => { + const { task, observed } = await bootStartup(['--help']) + expect(observed.out).toContain('dsh --profile headless') + expect(task).toBeUndefined() + expect(observed.exits).toEqual([0]) + }) + it('fails the boot when the composition has no runner row to give the task to', async () => { await expect(bootStartup(['task'], { withoutRunner: true })) .rejects.toThrow('the composition has no waiting "headless-runner" row') }) - - it('prints its own help and starts nothing', async () => { - const observed = await bootStartup(['--help']) - expect(observed.out).toContain('dsh --profile headless') - expect(observed.started).toEqual({}) - expect(observed.exits).toEqual([0]) - }) }) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 7825d68383..bc410b698b 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -5,11 +5,13 @@ # A patch replaces the targeted row's whole `config`, so each row below # restates every key it owns. # -# Rows this app configures from flags declare `inject: [webStartup]`: they wait -# until the web-startup row has parsed --host/--port/--dev/--workspace-root/ -# --trusted-host and provided that service with the resolved values. -# `dsh --profile web --help` therefore prints this app's own help and exits -# without ever binding a port. +# Rows this app configures from flags read them from the `webStartup` service: +# each names the key it takes and the value it falls back to, so a flag wins +# over the value written beside it. The web-startup row is this bundle's +# manifest-declared entrypoint, so it runs before any of them and has already +# parsed --host/--port/--dev/--workspace-root/--trusted-host by the time their +# config is resolved. `dsh --profile web --help` therefore prints this app's own +# help and exits before the rest of the composition mounts at all. # ── surface-specific values the base deliberately omits ───────────────────── @@ -79,9 +81,13 @@ # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' + inject: [webStartup] + config: + workspaceRoot: !!js ctx.get('webStartup')?.workspaceRoot - # Owns the web flag family and its --help; provides webStartup with the - # values this invocation resolved. Nothing waiting on it starts first. + # This bundle's entrypoint (declared in its package.json): it owns the web + # flag family and its --help, and provides webStartup with the values this + # invocation resolved. The boot runs it before every row above. - id: web-startup name: '@deepseek-ai/dsh-web-app/startup' @@ -94,8 +100,8 @@ name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: - host: 127.0.0.1 - port: 3080 + host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1' + port: !!js ctx.get('webStartup')?.port ?? 3080 # Web glue owned by this bundle: resolves the built frontend dist (an # assembly fact of dsh-web-app, never user config), mounts the @@ -108,11 +114,15 @@ name: '@deepseek-ai/dsh-web-app' inject: [webStartup] config: - mode: production + mode: !!js ctx.get('webStartup')?.mode ?? 'production' printUrl: true surfaceContext: true + lanAddresses: !!js ctx.get('webStartup')?.lanAddresses ?? [] - # The client-plugin HMR receiver ships disabled; `--dev` enables it. + # The client-plugin reload chain: a dev-only row this bundle ships off, + # which the entrypoint turns on for `--dev`. It is a row rather than a + # child of web-runtime because its node half is a client-side package, + # which a host-side bundle cannot import. - id: client-hmr name: '@deepseek-ai/dsh-client-hmr' inject: [webStartup] @@ -132,6 +142,11 @@ - id: connection name: '@deepseek-ai/dsh-client-connection' inject: [webStartup] + config: + # The LAN literals an all-interfaces bind derived plus the + # --trusted-host extras. A deployment that configures its own fence + # authorities adds them to this list. + trustedHosts: !!js ctx.get('webStartup')?.trustedHosts ?? [] - id: api-remotes name: '@deepseek-ai/dsh-api-remotes' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index e8240e1b63..0e2eff0e3b 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -33,7 +33,8 @@ "license": "BSD-3-Clause", "dsh": { "bundle": { - "patch": "./cordis.patch.yml" + "patch": "./cordis.patch.yml", + "entrypoint": "web-startup" } }, "dependencies": { diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 286a006f28..abf2ac4ca3 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -13,6 +13,7 @@ import { createRequire } from 'node:module' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { enableRow } from '@deepseek-ai/dsh-cmdline' import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static' import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' @@ -22,6 +23,9 @@ import type {} from '@deepseek-ai/dsh-bash-env' /** Stable Cordis plugin name. */ export const name = 'web-app' +/** The client-plugin reload chain row this bundle ships disabled, for `--dev`. */ +const HMR_ROW_ID = 'client-hmr' + /** Services required before the web runtime can mount. */ export const inject = ['httpServer'] @@ -112,6 +116,11 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex */ export function apply(ctx: Context, config: Config): void { ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) + // The client-plugin reload chain is a row this bundle ships off, because it + // exists only in development. Turning it on belongs here rather than in the + // entrypoint: it needs the host rows this phase of the boot mounts, and the + // entrypoint runs before them. + if (config.mode === 'development') void enableRow(ctx, HMR_ROW_ID) if (config.surfaceContext) { ctx.inject(['systemPrompt'], (promptCtx) => { promptCtx.systemPrompt.section({ @@ -143,15 +152,20 @@ export function apply(ctx: Context, config: Config): void { const port = ctx.httpServer.port console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) } - const loader = ctx.get('loader') - if (loader === undefined) printUrl() + // A launcher that mounts in phases tells this row when the whole + // composition is up; Loader settlement alone would let the line print + // between phases, announcing a server whose boot can still fail. A + // hand-built tree has neither and prints at once. + const settled = ctx.get('appReady') ?? ctx.get('loader')?.await() + if (settled === undefined) printUrl() else { - void loader.await().then(() => { - // The tree can be disposed while settlement was in flight (early + void settled.then(() => { + // The tree can be disposed while the boot was in flight (early // SIGTERM); a URL line for a dead server would only mislead, and // reading the torn-down port would turn a clean shutdown into a crash. if (ctx.get('httpServer') !== undefined) printUrl() - }) + // A failed boot is reported by the launcher; this row only stays quiet. + }, () => {}) } } } diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index 1b1969b0d6..96692e0116 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -12,7 +12,7 @@ import { networkInterfaces } from 'node:os' import { Command } from 'commander' import type { Context } from 'cordis' import type { EntryOptions } from '@cordisjs/plugin-loader' -import { overrideConfig, runStartup, type RowChange } from '@deepseek-ai/dsh-cmdline' +import { runStartup } from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ export const name = 'web-startup' @@ -21,12 +21,32 @@ export const name = 'web-startup' export const inject = ['cmdlineArgs'] /** - * The startup service every flag-configured web row injects. The rows are - * listed in this bundle's `cordis.patch.yml`; a row this startup plans changes - * for without injecting the service fails loud. + * The service this row provides and every flag-configured web row reads. The + * rows are listed in this bundle's `cordis.patch.yml`, where each names the key + * it takes from here and the value it falls back to. */ export const WEB_STARTUP_SERVICE = 'webStartup' +/** What the web rows read from {@link WEB_STARTUP_SERVICE}. */ +export interface WebStartupValues { + /** `--host`, absent when the invocation did not name one. */ + host?: string + /** `--port`, absent when the invocation did not name one. */ + port?: number + /** `--workspace-root`, absent when the invocation did not name one. */ + workspaceRoot?: string + /** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */ + mode: 'production' | 'development' + /** + * The `/api` fence authorities for this invocation: the LAN literals an + * all-interfaces bind derived, plus the `--trusted-host` extras, over what + * the composition already configured. + */ + trustedHosts: string[] + /** The LAN literals the fence was configured with, for display. */ + lanAddresses: string[] +} + /** The webserver schema's all-interfaces bind literal: only this bind derives LAN authorities. */ const ALL_INTERFACES_HOST = '0.0.0.0' @@ -95,58 +115,39 @@ Examples: } /** - * Turn the parsed flags into the changes each waiting row needs. + * Turn the parsed flags into the values the web rows read. * @param program - the parsed web command. * @param rows - the waiting rows' composed options, in tree order. - * @returns row id → changes; rows absent from the map start on their composed values. + * @returns the web rows' service value. */ -function planWebStartup(program: Command, rows: readonly EntryOptions[]): Map<string, RowChange> { +function planWebStartup(program: Command, rows: readonly EntryOptions[]): WebStartupValues { const options = program.opts<WebOptions>() if (options.port !== undefined && !/^\d+$/.test(options.port)) { program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) } - const row = (id: string): EntryOptions => { - const found = rows.find(candidate => candidate.id === id) - if (found === undefined) throw new Error(`web-startup: the web composition has no waiting "${id}" row to configure`) - return found - } - const plan = new Map<string, RowChange>() - const webserver = row('webserver') - const composedHost = (webserver.config as { host?: string } | undefined)?.host - plan.set('webserver', overrideConfig(webserver, { + const webserver = rows.find(row => row.id === 'webserver') + if (webserver === undefined) throw new Error('web-startup: the web composition has no waiting "webserver" row to configure') + // The bind this invocation ends on: the flag, else what the row falls back + // to, which is the same literal its config expression names. + const bindHost = options.host ?? (webserver.config as { host?: string } | undefined)?.host + const { lanAddresses, trustedHosts } = resolveLanTrust(bindHost, options.trustedHost ?? []) + return { ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, - })) - if (options.workspaceRoot !== undefined) { - plan.set('api-gateway', overrideConfig(row('api-gateway'), { workspaceRoot: options.workspaceRoot })) - } - const { lanAddresses, trustedHosts } = resolveLanTrust(options.host ?? composedHost, options.trustedHost ?? []) - if (trustedHosts.length > 0) { - // Additive over the composed value: a cordis.patch.yml-configured fence - // authority must survive the derived LAN literals and the flag extras — - // dropping it silently would weaken security-relevant configuration. - const connection = row('connection') - const composedTrusted = (connection.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? [] - plan.set('connection', overrideConfig(connection, { trustedHosts: [...composedTrusted, ...trustedHosts] })) - } - // mode and lanAddresses are resolved on every boot, never pass-throughs of - // composed values: they describe this invocation, not the deployment. - plan.set('web-runtime', overrideConfig(row('web-runtime'), { + ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, + // mode and lanAddresses describe this invocation, never the deployment, so + // they are resolved on every boot. mode: options.dev === true ? 'development' : 'production', + trustedHosts, lanAddresses, - })) - // The receiver ships disabled so `--dev` is a row toggle rather than a - // runtime insert (the Loader cannot resolve a row inserted from inside a - // mounting plugin). - if (options.dev === true) plan.set('client-hmr', { disabled: false }) - return plan + } } /** - * Resolve the web flag family and start the rows waiting for it. + * Resolve the web flag family and start the rows that read it. * @param ctx - plugin context carrying the command line and the Loader. - * @returns nothing once the waiting rows are released, or once `--help` requested exit. + * @returns nothing once the web rows are started, or once `--help` requested exit. */ -export function apply(ctx: Context): Promise<void> { - return runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup) +export function apply(ctx: Context): void { + runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup) } diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index c3cdfa08dc..7ac0f5192c 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -1,8 +1,8 @@ /** - * The web app's startup row over a REAL Loader tree carrying this bundle's - * waiting row ids: flags reach the rows they configure, absent flags leave the - * composed values standing, `--dev` enables the shipped-disabled HMR receiver, - * and `--help` leaves the app unstarted. + * The web app's entrypoint row over a REAL Loader tree: every flag lands in the + * `webStartup` service the web rows read, the bind it reports comes from the + * flag or from what the composition falls back to, `--help` resolves nothing, + * and a rejected argument exits without resolving anything. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -14,7 +14,7 @@ import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' import { afterEach, describe, expect, it, vi } from 'vitest' -import { apply, WEB_STARTUP_SERVICE } from '../src/startup.ts' +import { apply, WEB_STARTUP_SERVICE, type WebStartupValues } from '../src/startup.ts' vi.mock('node:os', async importOriginal => ({ ...await importOriginal<typeof import('node:os')>(), @@ -26,8 +26,6 @@ vi.mock('node:os', async importOriginal => ({ /** What one boot of the fixture tree observed. */ interface Observed { - /** Config each waiting row started with, by row id; absent means it never started. */ - started: Record<string, Record<string, unknown>> exits: number[] out: string } @@ -40,57 +38,57 @@ afterEach(async () => { internals.stderr = process.stderr }) -/** One stand-in for a row this bundle's patch makes wait for the web startup. */ -interface WaitingRow { - id: string - config?: Record<string, unknown> - disabled?: boolean -} - -/** The waiting rows this bundle's patch declares, with the composed values they ship. */ -const WAITING_ROWS: WaitingRow[] = [ - { id: 'webserver', config: { host: '127.0.0.1', port: 3080 } }, - { id: 'api-gateway', config: { provider: 'deepseek-official' } }, - { id: 'connection', config: { trustedHosts: ['configured.internal'] } }, - { id: 'web-runtime', config: { mode: 'production', printUrl: true } }, - { id: 'client-hmr', disabled: true }, -] - /** - * Boot the real startup row over stand-ins for this bundle's waiting rows. + * Mount the real entrypoint row over a stand-in for the `webserver` row whose + * composed bind it reads, the way a profile mounts phase one. * @param args - the invocation's inner arguments. - * @returns what the boot observed. + * @param webserverConfig - the composed `webserver` row config, or `null` to omit the row. + * @returns the resolved service value (absent when the app requested exit) and what the boot observed. */ -async function bootStartup(args: string[], rows: readonly WaitingRow[] = WAITING_ROWS): Promise<Observed> { +async function bootStartup( + args: string[], + webserverConfig: Record<string, unknown> | null = { host: '127.0.0.1', port: 3080 }, +): Promise<{ values: WebStartupValues | undefined; observed: Observed; ctx: Context }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-')) - const observed: Observed = { started: {}, exits: [], out: '' } - writeFileSync(join(dir, 'row.mjs'), ` -export function apply(ctx, config) { globalThis.__webStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } -`) + const observed: Observed = { exits: [], out: '' } + writeFileSync(join(dir, 'row.mjs'), 'export function apply() {}\n') // The Loader imports a row through Node's own resolver, which cannot resolve // this workspace's sources; the row delegates to the real plugin the test // imported through the source-plane path mapping. - writeFileSync(join(dir, 'startup-row.mjs'), ` + writeFileSync(join(dir, 'entrypoint.mjs'), ` export const name = 'web-startup' export const inject = ['cmdlineArgs'] export const apply = ctx => globalThis.__webStartupApply(ctx) `) const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href - const lines = rows.flatMap(row => [ - `- id: ${row.id}`, + writeFileSync(join(dir, 'cordis.yml'), [ + ...webserverConfig === null ? [] : [ + '- id: webserver', + ` name: ${rowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ' disabled: true', + ' config:', + ...Object.entries(webserverConfig).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`), + ], + // A second reader keeps the composition honest when the webserver row is + // the one under test: the service must still have someone to serve. + '- id: web-runtime', ` name: ${rowUrl}`, ` inject: [${WEB_STARTUP_SERVICE}]`, - ...row.disabled === true ? [' disabled: true'] : [], - ...row.config === undefined ? [] : [' config:', ...Object.entries(row.config).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`)], - ]) - lines.push('- id: web-startup', ` name: ${pathToFileURL(join(dir, 'startup-row.mjs')).href}`) - writeFileSync(join(dir, 'cordis.yml'), lines.join('\n') + '\n') + ' disabled: true', + // The reload chain this bundle ships off, which `--dev` turns on. + '- id: client-hmr', + ` name: ${rowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ' disabled: true', + '- id: web-startup', + ` name: ${pathToFileURL(join(dir, 'entrypoint.mjs')).href}`, + '', + ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } internals.stdout = observing internals.stderr = observing - const globals = globalThis as unknown as { __webStartupObserved: Observed; __webStartupApply: typeof apply } - globals.__webStartupObserved = observed - globals.__webStartupApply = apply + ;(globalThis as unknown as { __webStartupApply: typeof apply }).__webStartupApply = apply const ctx = new Context() await ctx.plugin(Loader) @@ -99,65 +97,67 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) await ctx.loader.await() disposers.push(async () => { await ctx.fiber.dispose() }) - return observed + return { values: ctx.get(WEB_STARTUP_SERVICE) as WebStartupValues | undefined, observed, ctx } } + describe('web startup', () => { - it('applies each flag to the row that owns it and leaves the rest composed', async () => { - const observed = await bootStartup(['--port', '8080', '--workspace-root', '/w']) - expect(observed.started.webserver).toEqual({ host: '127.0.0.1', port: 8080 }) - expect(observed.started['api-gateway']).toEqual({ provider: 'deepseek-official', workspaceRoot: '/w' }) - expect(observed.started['web-runtime']).toEqual({ mode: 'production', printUrl: true, lanAddresses: [] }) - expect(observed.started['client-hmr']).toBeUndefined() - expect(observed.exits).toEqual([]) + it('resolves each flag into the value its row reads', async () => { + const { values } = await bootStartup(['--port', '8080', '--workspace-root', '/w']) + expect(values).toEqual({ + port: 8080, + workspaceRoot: '/w', + mode: 'production', + trustedHosts: [], + lanAddresses: [], + }) }) - it('starts every row on its composed values when the invocation carries no flags', async () => { - const observed = await bootStartup([]) - expect(observed.started.webserver).toEqual({ host: '127.0.0.1', port: 3080 }) - expect(observed.started.connection).toEqual({ trustedHosts: ['configured.internal'] }) + it('names no value for a flag the invocation left out, so each row keeps its own', async () => { + const { values } = await bootStartup([]) + expect(values).toEqual({ mode: 'production', trustedHosts: [], lanAddresses: [] }) + expect(values).not.toHaveProperty('host') + expect(values).not.toHaveProperty('port') }) - it('adds the LAN literals over the configured fence authorities for an all-interfaces bind', async () => { - const observed = await bootStartup(['--host', '0.0.0.0', '--trusted-host', 'lab.internal']) - expect(observed.started.webserver).toEqual({ host: '0.0.0.0', port: 3080 }) - expect(observed.started.connection).toEqual({ trustedHosts: ['configured.internal', '192.168.1.5', 'lab.internal'] }) + it('derives the LAN literals for an all-interfaces bind, and the extras with them', async () => { + const { values } = await bootStartup(['--host', '0.0.0.0', '--trusted-host', 'lab.internal']) + expect(values?.trustedHosts).toEqual(['192.168.1.5', 'lab.internal']) // Display gets the same single sample the fence was configured with. - expect(observed.started['web-runtime']).toEqual({ mode: 'production', printUrl: true, lanAddresses: ['192.168.1.5'] }) + expect(values?.lanAddresses).toEqual(['192.168.1.5']) }) - it('enables the shipped-disabled HMR receiver for --dev', async () => { - const observed = await bootStartup(['--dev']) - expect(observed.started['client-hmr']).toEqual({}) - expect(observed.started['web-runtime']).toEqual({ mode: 'development', printUrl: true, lanAddresses: [] }) + it('reads the composed bind when no flag names one, so a configured 0.0.0.0 still derives them', async () => { + const { values } = await bootStartup([], { host: '0.0.0.0', port: 3080 }) + expect(values?.lanAddresses).toEqual(['192.168.1.5']) }) - it('prints its own help and starts nothing', async () => { - const observed = await bootStartup(['--help']) + it('reports the development mode for --dev, which the web runtime reads', async () => { + const { values } = await bootStartup(['--dev']) + // The runtime row is what turns the reload chain on, in the phase whose + // host rows it needs; this row only reports the mode. + expect(values?.mode).toBe('development') + }) + + it('prints its own help and resolves nothing', async () => { + const { values, observed } = await bootStartup(['--help']) expect(observed.out).toContain('dsh --profile web') expect(observed.out).toContain('--trusted-host') - expect(observed.started).toEqual({}) + expect(values).toBeUndefined() expect(observed.exits).toEqual([0]) }) - it('fails the boot when the composition lost a row this app configures', async () => { - // The bundle patch and this startup plugin must agree on the row set; a - // missing row would otherwise silently drop the flag that targets it. - const withoutWebserver = WAITING_ROWS.filter(row => row.id !== 'webserver') - await expect(bootStartup([], withoutWebserver)) - .rejects.toThrow('the web composition has no waiting "webserver" row') - }) - - it('derives the fence authorities alone when the composition configured none', async () => { - const withoutTrust = WAITING_ROWS.map(row => row.id === 'connection' ? { id: 'connection' } : row) - const observed = await bootStartup(['--host', '0.0.0.0'], withoutTrust) - expect(observed.started.connection).toEqual({ trustedHosts: ['192.168.1.5'] }) - }) - it('rejects a non-numeric port before anything binds', async () => { - const observed = await bootStartup(['--port', 'abc']) + const { values, observed } = await bootStartup(['--port', 'abc']) expect(observed.out).toContain('--port must be a number') - expect(observed.started).toEqual({}) + expect(values).toBeUndefined() expect(observed.exits).toEqual([1]) }) + + it('fails the boot when the composition lost the row whose bind it reads', async () => { + // The bundle patch and this entrypoint must agree on the row set; a + // missing row would otherwise silently drop the flag that targets it. + await expect(bootStartup([], null)) + .rejects.toThrow('the web composition has no waiting "webserver" row to configure') + }) }) diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index eeb2aefc07..ab56e87db4 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -131,6 +131,38 @@ describe('web-app runtime glue', () => { await ctx.fiber.dispose() }) + it('waits for the launcher readiness the phased boot provides, and stays quiet when that boot failed', async () => { + stageDist() + // The launcher-provided readiness wins over Loader settlement: a phased + // boot settles the Loader between phases, long before the app is up. + const ready = new Context() + ready.provide('httpServer', fakeHttpServer().server) + ready.provide('loader', { await: () => Promise.resolve() } as never) + let announce: () => void + ready.provide('appReady', new Promise<void>((resolve) => { announce = resolve })) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + apply(ready, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).not.toHaveBeenCalled() + announce!() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') + await ready.fiber.dispose() + + // A boot that failed announces nothing: the launcher reports it, and a URL + // for a process that is about to exit would only mislead. + log.mockClear() + const failed = new Context() + failed.provide('httpServer', fakeHttpServer().server) + const rejection = Promise.reject(new Error('boot failed')) + rejection.catch(() => {}) + failed.provide('appReady', rejection) + apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).not.toHaveBeenCalled() + await failed.fiber.dispose() + }) + it('defers the URL line until Loader settlement and drops it when the server is gone', async () => { stageDist() // Settlement path: the line waits for loader.await() so supervisors can From b692f38506f4b1f0ff2e74f6ee017b691c52feb5 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 7 Aug 2026 15:43:26 +0800 Subject: [PATCH 159/229] refactor(cli): discover app startup rows from injection --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 33 +++---- .../2026-08-06-app-owned-command-line.zh.md | 33 +++---- docs/user/develop/basic/publish.i18n.yaml | 4 +- docs/user/develop/basic/publish.md | 17 +++- docs/user/develop/basic/publish.zh.md | 29 ++++-- docs/user/guide/config.i18n.yaml | 4 +- docs/user/guide/config.md | 11 ++- docs/user/guide/config.zh.md | 13 ++- packages/boot/app-boot/src/index.ts | 7 +- packages/boot/app-boot/src/profile.ts | 50 +--------- packages/boot/app-boot/tests/profile.spec.ts | 32 ------- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 24 +++-- packages/boot/cmdline/README.zh.md | 24 +++-- packages/boot/cmdline/package.json | 3 +- packages/boot/cmdline/src/index.ts | 66 +++++++------ packages/boot/cmdline/tests/cmdline.spec.ts | 92 +++++++++++++------ packages/bundle/headless/cordis.patch.yml | 7 +- packages/bundle/headless/package.json | 5 +- packages/bundle/headless/src/startup.ts | 8 +- .../bundle/headless/tests/startup.spec.ts | 11 ++- packages/bundle/web-app/cordis.patch.yml | 17 ++-- packages/bundle/web-app/package.json | 3 +- packages/bundle/web-app/src/index.ts | 33 ++++--- packages/bundle/web-app/tests/startup.spec.ts | 11 ++- packages/bundle/web-app/tests/web-app.spec.ts | 37 +++++--- pnpm-lock.yaml | 3 - 28 files changed, 302 insertions(+), 283 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index c0ec7836ae..f38cc36d1e 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 4765629c0cc3fee1d850de215af18bdbe51324bb -2026-08-06-app-owned-command-line.zh.md: 48782fbb9ce53ba9b3e8dbc6c2f746c7f1d46ea1 +2026-08-06-app-owned-command-line.md: e533338118f1b195589ed05ad972d1d4a55e610c +2026-08-06-app-owned-command-line.zh.md: 00f492629fd08383726e71ad7eea608df22fb772 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 4765629c0c..e533338118 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -12,39 +12,40 @@ After profiles, compositions were installable but their command lines were not. The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. -The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`), `ctx.appExit`, and `ctx.appReady`. An app consumes them from its **entrypoint row** — named by its bundle manifest (`dsh.bundle.entrypoint`) — which injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)` with its own commander program, then provides what it resolved as its own service. The rows the app configures read that service from their own config expressions (`port: !!js ctx.get('webStartup')?.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`), `ctx.appExit`, and `ctx.appReady`. An app consumes them from its **startup row**. Both the Loader row and plugin inject `cmdlineArgs`; the plugin calls `runStartup(ctx, service, program, plan)` with its own commander program and provides what it resolved as its own service. The Loader-row injection is also the launcher's discovery declaration; there is no parallel bundle-manifest field. The rows the app configures inject that service and read it from their own config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. -The boot mounts in two passes, which is what the manifest declaration buys: entrypoints alone, then the whole composition. A row's config expressions are evaluated when the include applies the row, and a strict `ctx.get` only answers for a service whose providing fiber is active, so the rest of the tree has to be applied after the entrypoints are up. `--help` therefore exits before the second pass exists, and a user editing a live patch file re-applies that pass against services that are still up, so a served port cannot be silently reset. +The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` provides no startup service, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. -The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any row id. Out of tree, turtle-ui gained `--resume <session>` / `--session <id>` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. +The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume <session>` / `--session <id>` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. -Two further consequences. Loader settlement stopped meaning "the app is up" — a row mounted in the second pass can observe a settled tree while the pass that mounted it is still going, or already rolling back — so a row that publishes readiness (the web URL line) awaits `ctx.appReady` instead. And `dsh --profile web` now adds the harness-source prompt section that only the `dsh web` alias used to add: the two paths finally boot identically, which also means a user profile named `web` inherits it. +Two further consequences. Loader mounts sibling rows concurrently, so one row can activate while another still mounts or while the whole boot is rolling back; a row that publishes readiness (the web URL line) therefore awaits `ctx.appReady`. The Web bundle's runtime plugin owns the harness-source prompt section too, so `dsh web` and `dsh --profile web` boot identically without Web-specific launcher setup. -## Why the boot has phases +## Why Loader owns the ordering -Four vendored-Loader facts shaped the mechanism, all found by probe: +Four framework facts shape the mechanism: -- **A profile's rows arrive as the root include's `patches` option, and an entry's whole config is interpolated when that entry starts.** Every `!!js` in every row is therefore evaluated once, when the include mounts — before any row exists. Rows in the root config *file* would interpolate per row, but a profile root is empty by design. -- **A strict `ctx.get` hides a service whose providing fiber is not yet ACTIVE**, and a plugin's own fiber is not active while its `apply` is still running. Providing a service and configuring rows from it in the same pass cannot work. -- **Updating a row's `inject` loses the plugin's own static injections.** The Loader restarts a replaced row from `runtime.callback`, the unwrapped function, and `Inject.resolve(plugin.inject)` then finds nothing: a row declaring `inject = ['httpServer', 'apiProxy']` comes back unable to read either. -- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and a row that mounts beside it enables it (`dsh web --dev` and its reload chain). +- **A profile's rows arrive inside the root include's `patches` option.** Include is an entry-tree owner, so its static entry-config resolver interpolates Include's own options while preserving nested `!!js` nodes for their target rows instead of recursively evaluating them in the Include context. +- **Cordis activates a fiber only after all declared injections are active.** Loader supplies a deferred config resolver to that fiber; the resolver runs immediately before each activation against the fiber's own context, after Cordis snapshots its injected services. +- **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the resolver, HMR carries it to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services. +- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and an active row enables it (`dsh web --dev` and its reload chain); the enabled row then follows ordinary injection ordering. -Together these rule out configuring rows from a service in one pass, and rule in the phased mount: rows keep their own `inject` and their own config, and the only thing the launcher does between phases is apply the composition again. +This puts dependency ordering at the seam that owns it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services. ## Alternatives considered - **Writing the resolved values into each row** (a config update per row, plus a patch layer handed back to the launcher so a reload could not undo it): it worked, but it meant patches travelling from an app to the launcher and back, two mechanisms for one fact, and a recycle whose correctness depended on Loader restart internals. The maintainer rejected the round trip; the service the rows read replaced all of it. - **Releasing rows by clearing their `inject`**: it worked in isolation and failed on the real web tree, because clearing `inject` is exactly what loses the plugin's static injections. The failure is silent until a plugin reads a service it declared. -- **Rows waiting on the service in a single-pass mount**: the config expressions are interpolated before any row exists, so every reader would see `undefined`. -- **The launcher running each bundle's startup function before boot** (no cordis involvement): strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. Declaring an entrypoint *row* keeps one protocol: the entrypoint is an ordinary row, dumpable and patchable, and a layering bundle disables it like any other. +- **Launcher-managed two-pass mounting**: it can make a provider active before readers are applied, but duplicates the composition, makes ordering a launcher concern, and conceals the Loader defect that nested expressions were evaluated in the include context rather than the target row's injected context. +- **The launcher running each bundle's startup function before boot** (no cordis involvement): strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. Using a `cmdlineArgs`-injected startup row keeps one protocol: it is an ordinary row, dumpable and patchable, and a layering bundle disables it like any other. - **Both apps parsing the same argv** (the one-shot bundle rides over the web bundle): two parsers cannot both own `-h`. A composition has exactly one command-line owner: the layering bundle disables the underlying startup row and names both startup services, so the absorbed rows start on their composed values. - **`instanceof CommanderError`**: an out-of-tree plugin brings its own commander copy, so the class identity differs and a printed `--help` was rethrown as a fatal load failure. Commander's control-flow errors are detected structurally instead. ## Consequences - An app's flags, help text, and usage errors live with the rows they configure; adding a flag to an installed plugin needs no launcher change. -- `--help` mounts only the entrypoints and exits, so nothing else in the composition ever starts. -- A startup service has no statically declared owner: a bundle shipping reading rows without its entrypoint fails at settlement with pending entries naming the service, not at load. +- The launcher still recognizes the headless runner for one-shot process lifetime and the telemetry row for its environment switch; neither path interprets app arguments. +- `--help` leaves every row that depends on a startup service pending and requests bounded exit; unrelated rows may activate concurrently before teardown. A profile with no active row injecting `cmdlineArgs` rejects nonempty app arguments before mounting instead of ignoring them. +- A startup service has no statically declared owner: a bundle shipping reading rows without its startup row fails at settlement with pending entries naming the service, not at load. - A user patch that replaces a row's whole `config` drops its expressions, and with them the flag's precedence for that row. -- Launcher flags must precede app arguments; a first app argument reading `web` or `plugin` selects those subcommands instead, and the launcher's parser consumes one `--`, so a literal `--` for the app needs `-- --`. +- Launcher flags must precede app arguments; a first app argument equal to `web` or `plugin` selects that subcommand instead, `-V`/`--version` remains launcher-owned before that boundary, and the launcher's parser consumes one `--`, so a literal `--` for the app needs `-- --`. - `--dump-config` never runs a startup row, so it prints the composition before any app argument is resolved and rejects an invocation that carries app arguments. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index 48782fbb9c..00f492629f 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -12,39 +12,40 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 -新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)、`ctx.appExit` 和 `ctx.appReady`。应用从自己的**入口点行**消费它们——该行由其组合包 manifest(元数据清单)点名(`dsh.bundle.entrypoint`),注入 `cmdlineArgs`,以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。应用所配置的行从各自的配置表达式中读取该服务(`port: !!js ctx.get('webStartup')?.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)、`ctx.appExit` 和 `ctx.appReady`。应用从自己的**启动行**消费它们。Loader 行与插件都注入 `cmdlineArgs`;插件以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。Loader 行的注入同时也是启动器的发现声明,不再需要一份平行的组合包 manifest 字段。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 -boot 分两趟挂载,这正是 manifest 声明所换来的:先是各入口点,然后才是整套组合。行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答,因此配置树的其余部分必须在入口点起来之后才施加。于是 `--help` 在第二趟存在之前就退出;用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新施加,因此已经服务中的端口不会被悄悄重置。 +boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 不提供启动服务,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 -已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何行 id。在树外,turtle-ui 以同样的方式获得了 `--resume <session>` / `--session <id>`,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 +已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume <session>` / `--session <id>`,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 -还有两条后果。Loader 结算不再意味着「应用已经起来」——在第二趟中挂载的行可能看到一棵已结算的树,而挂载它的那一趟仍在进行,甚至已经在回滚——因此公布就绪信号的行(web 的 URL 行)改为等待 `ctx.appReady`。另外,`dsh --profile web` 现在也会加上过去只有 `dsh web` 别名才会加的 harness 源码提示词章节:两条路径终于以完全相同的方式引导,这也意味着名为 `web` 的用户 profile 会继承它。 +还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以公布就绪信号的行(web 的 URL 行)会等待 `ctx.appReady`。另外,Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web` 与 `dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。 -## 为什么 boot 分阶段 +## 为什么由 Loader 持有顺序 -vendored Loader 的四个事实塑造了这套机制,它们都是靠探针试出来的: +四条框架事实塑造了这套机制: -- **profile 的各行是作为根 include 的 `patches` 选项送达的,而一个条目的整份配置会在该条目启动时被插值。** 因此每一行里的每个 `!!js` 都会在 include 挂载时一次性求值——早于任何行的存在。位于根配置*文件*中的行会逐行插值,但 profile 的根按设计就是空的。 -- **严格的 `ctx.get` 会隐藏提供方 fiber 尚未 ACTIVE 的服务**,而插件自身的 fiber 在其 `apply` 仍在运行时并未 active。在同一趟里既提供服务又用它配置各行,是不可能成立的。 -- **更新一行的 `inject` 会丢失插件自身的静态注入。** Loader 从 `runtime.callback`(未经包装的函数)重启被替换的行,此时 `Inject.resolve(plugin.inject)` 什么也找不到:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。 -- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,由与它同趟挂载的行来启用(`dsh web --dev` 及其重载链路)。 +- **profile 的各行位于根 include 的 `patches` 选项内部。** Include 是条目树所有者,因此它的静态条目配置解析器会插值 Include 自身的选项,同时为目标行保留嵌套的 `!!js` 节点,而不是在 Include 上下文中递归求值。 +- **Cordis 只在所有声明的注入都已激活后才激活 fiber。** Loader 为该 fiber 提供延迟配置解析器;Cordis 快照注入服务之后,解析器会在每次激活前一刻基于 fiber 自身上下文运行。 +- **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑解析器,HMR 会把它带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。 +- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路);启用后的行继续遵循普通注入顺序。 -这些事实合起来排除了「一趟之内用服务配置各行」,并确立了分阶段挂载:各行保留自己的 `inject` 和自己的配置,而启动器在两阶段之间所做的,仅仅是再施加一次组合。 +这样,依赖顺序就由真正持有它的接缝负责。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。 ## 曾考虑的替代方案 - **把解析出的取值写进每一行**(逐行一次配置更新,外加交还给启动器的一层 patch,使重载无法撤销它):它能工作,但这意味着 patch 在应用与启动器之间来回传递、同一件事有两套机制,以及一套其正确性依赖 Loader 重启内部细节的回收重建。维护者否决了这次往返;供各行读取的服务取代了这一切。 - **通过清空行的 `inject` 来放行**:孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 -- **在单趟挂载中让各行等待该服务**:配置表达式在任何行存在之前就已插值,因此每个读取方都会看到 `undefined`。 -- **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。声明一个入口点*行*则只保留一套协议:入口点就是一个普通的行,可 dump、可 patch,叠加的组合包也能像禁用其他行那样禁用它。 +- **由启动器管理两趟挂载**:它可以让提供方先于读取行激活,但会重复组合、把顺序变成启动器职责,还掩盖了 Loader 的缺陷——嵌套表达式在 include 上下文而不是目标行的注入上下文中求值。 +- **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。使用注入 `cmdlineArgs` 的启动行则只保留一套协议:它就是一个普通的行,可 dump、可 patch,叠加的组合包也能像禁用其他行那样禁用它。 - **两个应用解析同一份 argv**(一次性组合包叠加在 web 组合包之上):两个解析器不可能同时持有 `-h`。一套组合有且只有一个命令行所有者:叠加的组合包禁用下层的启动行,并同时提供这两个启动服务,使被吸收的行按组合后的取值启动。 - **`instanceof CommanderError`**:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的 `--help` 会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。 ## 后果 - 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。 -- `--help` 只挂载各入口点然后退出,组合中的其余部分从不启动。 -- 启动服务没有静态声明的所有者:交付了读取行却缺少对应入口点的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 +- 启动器仍会识别 headless runner 以管理一次性进程生命周期,并识别 telemetry 行以应用环境开关;两条路径都不解析应用参数。 +- `--help` 会让所有依赖启动服务的行保持待处理并请求有边界的退出;无关行可能在拆除前并发激活。没有注入 `cmdlineArgs` 的活跃行的 profile 会在挂载前拒绝非空应用参数,而不是忽略它们。 +- 启动服务没有静态声明的所有者:交付了读取行却缺少对应启动行的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 - 用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉,该行上 flag 的优先级也随之消失。 -- 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好是 `web` 或 `plugin`,选中的将是这两个子命令,而且启动器的解析器会消耗掉一个 `--`,因此要给应用传一个字面量 `--` 需要写成 `-- --`。 +- 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令;`-V`/`--version` 在该边界之前仍归启动器持有;而且启动器的解析器会消耗掉一个 `--`,因此要给应用传一个字面量 `--` 需要写成 `-- --`。 - `--dump-config` 从不运行启动行,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。 diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index d849ac4ae0..963fe17378 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/publish.md -publish.md: 7657654b1467c14b22e0eb6372c2bc4e77db2f38 -publish.zh.md: 7af2ae3a06cc74597d5cbd6fddd46fbab069e287 +publish.md: c81e53d75ecccd31c9051f33252854dbe156c566 +publish.zh.md: c5a15be00bea838eb534dbf608c29d3832c2c0e1 diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 7657654b14..c81e53d75e 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -98,7 +98,8 @@ The effective configuration composes over an empty root by applying, in order: 2. The profile's own `cordis.patch.yml`. 3. The home-level `$DSH_HOME/cordis.patch.yml` — machine-local preferences shared by every profile. 4. Each `--patch <path>` overlay, in argv order. -5. Launcher flag patches (for example `dsh web --port`). + +App arguments are not another patch layer. A surface bundle can resolve them through a startup service, described below. Later layers win per row, and a patch replaces a row's entire `config` value rather than deep-merging keys. Two consequences for bundle authors: @@ -107,6 +108,20 @@ Later layers win per row, and a patch replaces a row's entire `config` value rat In-box bundle names always resolve from the dsh installation itself; pnpm manages only out-of-tree packages, so your bundle can rely on `@deepseek-ai/dsh-base` being present and current. +## Give a surface bundle its own command line + +A bundle that defines a runnable app marks its startup row through the injection it already requires: + +```yaml +- id: hello-startup + name: 'dsh-hello-plugin/startup' + inject: [cmdlineArgs] +``` + +That row calls `runStartup` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/ui/cmdline/README.md) with the app's own commander program. The launcher hands it every argument after the launcher flags, so app-specific flags need no launcher change. Loader mounts the composition once, waits for each row's injections, and only then evaluates that row's `!!js` config against its injected context. + +Rows configured by those arguments inject the startup service and read it from their own `!!js` options, with the deployment value beside it as the fallback. On `--help`, the service is not provided, so those rows never activate. An app layered over another app disables the lower startup row, because one composition has one command-line owner. + ## Installing from GitHub: the build-script catch Publishing to a registry is not required — users can install straight from a git host: diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index 7af2ae3a06..c5a15be00b 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -2,14 +2,14 @@ [English](publish.md) | 中文 -前几篇教程通过 `--patch` overlay 加载本地插件。本教程把它打包成可安装的**组合包**,用 `dsh plugin add` 安装进一个 **profile**,并解释决定组合后配置的层顺序。请先完成[插件配置](./config.md)。 +前几篇教程通过 `--patch` overlay 加载本地插件。本教程把它打包成可安装的**组合包**(bundle),用 `dsh plugin add` 安装进一个 **profile**,并解释决定组合后配置的层顺序。请先完成[插件配置](./config.md)。 -## 两个概念,两种 manifest(元数据清单) +## 两个概念,两种 manifest -安装机制建立在两个概念之上。二者都由一份 `package.json` 描述,但它们在 `dsh` 键下携带的 manifest 种类不同,回答的问题也不同: +安装机制建立在两个概念之上。二者都由一份 `package.json` 描述,但它们在 `dsh` 键下携带的 manifest(元数据清单)种类不同,回答的问题也不同: -- **组合包**是附带一个配置层的 npm 包。它的 manifest 声明 `dsh.bundle`,回答的是「这个包贡献什么?」:一个插入或覆盖插件行的 patch 文件。 -- **profile** 是位于 `$DSH_HOME/profiles/<name>` 下、描述一份可启动组合的目录。它的 manifest 声明 `dsh.profile`,回答的是「这套配置由哪些组合包按什么顺序组成?」。 +- **组合包**是附带一个配置层的 npm 包。它的 manifest 声明 `dsh.bundle`,回答的是"这个包贡献什么?":一个插入或覆盖插件行的 patch 文件。 +- **profile** 是位于 `$DSH_HOME/profiles/<name>` 下、描述一份可启动组合的目录。它的 manifest 声明 `dsh.profile`,回答的是"这套配置由哪些组合包按什么顺序组成?"。 组合包是你编写并分发的东西;profile 是用户用 `dsh --profile <name>` 启动的东西。没有东西同时是两者。 @@ -98,7 +98,8 @@ dsh --profile demo 2. profile 自己的 `cordis.patch.yml`。 3. home 级的 `$DSH_HOME/cordis.patch.yml`——各 profile 共享的机器本地偏好。 4. 每个 `--patch <path>` overlay,按 argv 顺序。 -5. 启动器 flag patch(例如 `dsh web --port`)。 + +应用参数不是另一层 patch。表层组合包可以通过下文所述的启动服务解析它们。 后应用的层按行胜出,且 patch 会替换目标行的整个 `config` 值,而不是深度合并各键。这给组合包作者带来两个推论: @@ -107,6 +108,20 @@ dsh --profile demo 内置组合包名称始终从 dsh 安装目录本身解析;pnpm 只管理树外的包,所以你的组合包可以放心依赖 `@deepseek-ai/dsh-base` 存在且与安装保持一致。 +## 让表层组合包持有自己的命令行 + +定义了可运行应用的组合包可以通过启动行本来就需要的注入来标记它: + +```yaml +- id: hello-startup + name: 'dsh-hello-plugin/startup' + inject: [cmdlineArgs] +``` + +该行使用应用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/ui/cmdline/README.md) 中的 `runStartup`。启动器把自身 flag 之后的所有参数交给它,因此添加应用专属 flag 无需修改启动器。Loader 只挂载一次组合,等待每一行的注入,再基于其已注入的上下文求值该行的 `!!js` 配置。 + +受这些参数配置的行会注入启动服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退。遇到 `--help` 时,该服务不会被提供,所以这些行不会激活。叠加在另一应用之上的应用会禁用下层启动行,因为一套组合只能有一个命令行所有者。 + ## 从 GitHub 安装:构建脚本这道坎 发布到注册表不是必须的——用户可以直接从 git 托管安装: @@ -127,7 +142,7 @@ dsh plugin --profile demo add github:you/hello-plugin 然后重新执行 `add`。 -请如实看待这项授权:**允许该包的代码在安装时于你的机器上执行**,且不在 agent(智能体)运行的任何沙箱之内。只对源码可信的包授权,并锁定 commit(`github:you/hello-plugin#<sha>`),让后续推送无法悄悄改变实际运行的内容。 +请如实看待这项授权:**允许该包的代码在安装时于你的机器上执行**,且不在 agent 运行的任何沙箱之内。只对源码可信的包授权,并锁定 commit(`github:you/hello-plugin#<sha>`),让后续推送无法悄悄改变实际运行的内容。 如果不想让用户做这项授权,就改为分发构建产物——以下两种形式都不需要任何构建权限: diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 7feb2c7f07..3010782d20 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/config.md -config.md: cd778065801ae58a46703ae3447f835f80abf062 -config.zh.md: 6f6d37bfe8f7ad29c154d65c1763279655006435 +config.md: 7a8492d45fc3710958853b8498f90f5a19b62f4a +config.zh.md: 62a1693a13cdd4b2428085187b73b69d429cde6e diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index cd77806580..7a8492d45f 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -18,6 +18,10 @@ A minimal configuration is a list of plugin entries: ```yaml - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: + - deepseek-v4-flash - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -47,16 +51,17 @@ Cordis starts sibling entries concurrently. A plugin declares required services ## CLI patch layers -`dsh --profile <name>` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles/<name>/cordis.patch.yml`, then each `--patch <path>` overlay, then CLI-flag patches. Later layers win per row. +`dsh --profile <name>` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles/<name>/cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and each `--patch <path>` overlay. Later layers win per row. App flags are not another patch layer: the bundle's `cmdlineArgs`-injected startup row resolves them into a service, and rows that retain a `!!js` read of that service give the invocation value precedence. -A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKeyEnv` and `baseURL`, so restate every key the row must retain. +A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. ## JavaScript values and environment variables -The Cordis loader evaluates runtime expressions tagged with `!!js` for non-secret runtime values. Bundled LLM adapters carry credential references such as `apiKeyEnv`; the value belongs in an environment layer or `$DSH_HOME/.credentials.yaml`, not Cordis configuration. +The Cordis loader evaluates runtime expressions tagged with `!!js`. Keep API keys and other secrets in the gitignored `.env` file at the repository root, never in committed configuration. ```yaml config: + apiKey: !!js process.env.DEEPSEEK_API_KEY cwd: !!js process.cwd() ``` diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 6f6d37bfe8..62a1693a13 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -18,6 +18,10 @@ Harness 使用 `cordis.yml` 描述 agent(智能体)加载哪些插件以及 ```yaml - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: + - deepseek-v4-flash - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -47,16 +51,17 @@ Cordis 会并发启动同级配置项。插件通过 `inject` 声明必需服务 ## CLI 补丁层 -`dsh --profile <name>` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles/<name>/cordis.patch.yml`、home 级 `$DSH_HOME/cordis.patch.yml`、每个 `--patch <path>` overlay,最后是 CLI(命令行界面)标志补丁。同一行以较后的层为准。 +`dsh --profile <name>` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles/<name>/cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 与每个 `--patch <path>` overlay。同一行以较后的层为准。应用 flag 并不是另一层 patch:组合包中注入 `cmdlineArgs` 的启动行把它们解析成服务,而保留了读取该服务的 `!!js` 表达式的行会让本次调用的取值优先。 -补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKeyEnv` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 +补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 ## JavaScript 值和环境变量 -Cordis loader 会求值以 `!!js` 标记的运行时表达式,用于非机密的运行时值。仓库内置的 LLM(大语言模型)适配器携带 `apiKeyEnv` 等凭据引用;对应的值应放在环境层或 `$DSH_HOME/.credentials.yaml`,而不是 Cordis 配置中。 +Cordis loader 使用 `!!js` 标签读取运行时表达式。API key 等凭据应放在仓库根目录、已被 Git 忽略的 `.env` 中,不能提交到配置文件。 ```yaml config: + apiKey: !!js process.env.DEEPSEEK_API_KEY cwd: !!js process.cwd() ``` @@ -64,4 +69,4 @@ config: ## 精确配置参考 -每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../architecture.md)和[能力 seam](../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../examples/README.md)中最接近的例子。 +每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../architecture.md)和[能力接口](../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../examples/README.md)中最接近的例子。 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index e5596229ae..5f8d6643a1 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -39,7 +39,6 @@ export { PROFILES_DIR, readProfileManifest, resolveBundleDir, - resolveEntrypoints, resolveProfileDir, writeProfileManifest, type DshBundleManifest, @@ -532,10 +531,10 @@ export async function mountRootInclude( * Re-apply the root include's patch list on a booted tree, and wait for the * result to settle. * - * This is how a boot mounts its composition in phases: an app's entrypoint row + * This is how a boot mounts its composition in phases: an app's startup row * resolves what the rest of the tree reads (`!!js ctx.get('webStartup')?.port`), * and a row's config expressions are evaluated when the include applies them — - * so the rest of the composition must be applied after the entrypoints are + * so the rest of the composition must be applied after the startup rows are * active, not before. * @param ctx - the booted context whose root include to re-apply. * @param patches - the full patch list for this generation. @@ -545,7 +544,7 @@ export async function mountRootInclude( export async function applyRootPatches(ctx: Context, patches: readonly PatchOptions[]): Promise<void> { const entry = bootstrapIncludes.get(ctx) if (entry === undefined) throw new Error('dsh: applying root patches requires the root Include entry') - // A surface can dispose the whole tree while an entrypoint is still parsing + // A surface can dispose the whole tree while a startup row is still parsing // (`--help`, or an early SIGTERM); there is then nothing left to mount. if (ctx.get('loader') === undefined) return const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index e105287808..e19bb13c41 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -42,16 +42,6 @@ export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml' export interface DshBundleManifest { /** The patch layer this bundle exports, relative to its package root. */ patch: string - /** - * Id of the row in that patch which must run before every other row of the - * composition — the app's entrypoint. - * - * An entrypoint resolves what the rest of the tree needs in order to be - * configured at all (the command line an app was invoked with), and provides - * it as a service. The boot mounts entrypoints alone first, so by the time - * any other row's config is resolved, `ctx.get('<service>')` answers. - */ - entrypoint?: string } /** The profile half of the `dsh` manifest section: what a profile directory composes. */ @@ -89,37 +79,6 @@ export interface ProfileLayer { patchPath: string /** The parsed patch list. */ patches: PatchOptions[] - /** Row id this bundle declares as its entrypoint, when it has one. */ - entrypoint?: string -} - -/** - * The composition's entrypoint row ids, in bundle order. - * @param binName - the diagnostic prefix on the thrown error. - * @param profile - the loaded profile. - * @param rows - the composed rows, so an entrypoint a later layer removed or - * disabled is not mounted (the one-shot bundle takes over the web one this way). - * @returns the row ids to mount before the rest of the tree. - * @throws when a bundle declares an entrypoint its own patch never inserts. - */ -export function resolveEntrypoints( - binName: string, - profile: Profile, - rows: readonly { id?: string; disabled?: boolean | null }[], -): string[] { - const entrypoints: string[] = [] - for (const layer of profile.layers) { - if (layer.entrypoint === undefined) continue - const row = rows.find(candidate => candidate.id === layer.entrypoint) - if (row === undefined) { - throw new Error( - `${binName}: bundle ${JSON.stringify(layer.packageName)} declares entrypoint ${JSON.stringify(layer.entrypoint)}, ` - + 'which the composed tree has no row for', - ) - } - if (row.disabled !== true) entrypoints.push(layer.entrypoint) - } - return entrypoints } /** A loaded profile: resolved bundle layers plus the user's own patch layer. */ @@ -432,14 +391,7 @@ export function loadProfile( throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`) } const patchPath = join(packageDir, declared) - const entrypoint = bundleManifest.dsh?.bundle?.entrypoint - return { - packageName, - packageDir, - patchPath, - patches: loadOverlayPatches(binName, patchPath), - ...entrypoint === undefined ? {} : { entrypoint }, - } + return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) } }) const patchPath = join(dir, PROFILE_PATCH_FILENAME) const patches = options.userLayer !== false && existsSync(patchPath) diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 48166042f0..bd0294475d 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -17,7 +17,6 @@ import { PROFILE_TEMPLATES, readProfileManifest, resolveBundleDir, - resolveEntrypoints, resolveProfileDir, writeProfileManifest, } from '../src/index.ts' @@ -198,37 +197,6 @@ describe('loadProfile', () => { }) }) -describe('resolveEntrypoints', () => { - const profile = (layers: { packageName: string; entrypoint?: string }[]): Parameters<typeof resolveEntrypoints>[1] => ({ - name: 'p', - dir: '/p', - patchPath: '/p/cordis.patch.yml', - patches: [], - layers: layers.map(layer => ({ ...layer, packageDir: '/b', patchPath: '/b/cordis.patch.yml', patches: [] })), - }) - - it('names each bundle entrypoint in bundle order', () => { - expect(resolveEntrypoints( - 'dsh', - profile([{ packageName: 'a' }, { packageName: 'b', entrypoint: 'b-startup' }, { packageName: 'c', entrypoint: 'c-startup' }]), - [{ id: 'b-startup' }, { id: 'c-startup' }, { id: 'other' }], - )).toEqual(['b-startup', 'c-startup']) - }) - - it('skips an entrypoint a later layer disabled, which is how one app takes over another', () => { - expect(resolveEntrypoints( - 'dsh', - profile([{ packageName: 'web', entrypoint: 'web-startup' }, { packageName: 'one-shot', entrypoint: 'one-shot-startup' }]), - [{ id: 'web-startup', disabled: true }, { id: 'one-shot-startup' }], - )).toEqual(['one-shot-startup']) - }) - - it('fails loud when a bundle declares an entrypoint its patch never inserts', () => { - expect(() => resolveEntrypoints('dsh', profile([{ packageName: 'b', entrypoint: 'absent' }]), [{ id: 'other' }])) - .toThrow('declares entrypoint "absent", which the composed tree has no row for') - }) -}) - describe('composeEntries', () => { it('applies layers over an empty root and reports skipped patches', () => { const warnings: string[] = [] diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 7c986b0d26..dcd4d2416d 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/cmdline/README.md -README.md: acdc3a310f0062f1b27dbd74d20b81e1a8198bca -README.zh.md: 365a2c7f3cdf5710ce7e3abe76f009dc1ba4217f +README.md: 242ba184507d88c50e0dcf2ada0a0f7714d87e28 +README.zh.md: 76a76ad6090fcc28d50f9ea2a48d4e2581e361f2 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index acdc3a310f..242ba18450 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -14,9 +14,9 @@ A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which An embedding host with no command line provides an empty list; that is the honest answer, not a missing value. -## Entrypoints, and the service their app reads +## Startup rows, and the service their app reads -An app reads those arguments from its **entrypoint row** — a plugin that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`: +An app reads those arguments from its **startup row** — a Loader row and plugin that inject `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`: ```ts ignore export const name = 'web-startup' @@ -27,13 +27,17 @@ export function apply(ctx: Context): void { } ``` -The bundle's `package.json` names that row, which is what makes the boot mount it before everything else: +The Loader-row injection is also its discovery declaration, so no bundle manifest field is needed: -```json -{ "dsh": { "bundle": { "patch": "./cordis.patch.yml", "entrypoint": "web-startup" } } } +```yaml +- id: web-startup + name: '@deepseek-ai/dsh-web-app/startup' + inject: [cmdlineArgs] ``` -Every row the app configures from flags then reads what the entrypoint resolved, naming the key it takes and the value it falls back to: +The launcher finds active rows with that injection in the composed tree and mounts them before everything else. + +Every row the app configures from flags then reads what the startup row resolved, naming the key it takes and the value it falls back to: ```yaml - id: webserver @@ -50,13 +54,13 @@ Every row the app configures from flags then reads what the entrypoint resolved, ### Why the boot has phases -A row's config expressions are evaluated when the include applies it, and a strict `ctx.get` only answers for a service whose providing fiber is already active. A composition therefore mounts in two passes: the entrypoints alone, then everything else — which is exactly what the manifest declaration buys. The rows of a later pass read live values, a `--help` exits before the second pass exists, and a user editing a live patch file re-runs that pass against services that are still up, so a flag cannot be silently reset. +A row's config expressions are evaluated when the include applies it, and a strict `ctx.get` only answers for a service whose providing fiber is already active. A composition therefore mounts in two passes: active `cmdlineArgs` consumers alone, then everything else. The rows of the later pass read live values, a `--help` exits before the second pass exists, and a user editing a live patch file re-runs that pass against services that are still up, so a flag cannot be silently reset. -`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). Call it from a row that mounts beside the one being enabled, not from an entrypoint: a row enabled in the first pass would wait for services the second pass has yet to mount. +`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). Call it from a row that mounts beside the one being enabled, not from the startup row: a row enabled in the first pass would wait for services the second pass has yet to mount. ### One command line, one owner -A composition has exactly one command-line owner. An app that layers over another one disables the underlying entrypoint row and names both services, so the rows it absorbed start on the values their own fallbacks name — [`dsh-headless`](../../bundle/headless/README.md) does this over [`dsh-web-app`](../../bundle/web-app/README.md). +A composition has exactly one command-line owner. An app that layers over another one disables the underlying startup row and names both services, so the rows it absorbed start on the values their own fallbacks name — [`dsh-headless`](../../bundle/headless/README.md) does this over [`dsh-web-app`](../../bundle/web-app/README.md). An out-of-tree plugin brings its own commander copy, so commander's control-flow errors are detected structurally rather than by class identity; an identity check would rethrow a printed help as a fatal load failure. @@ -71,5 +75,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Launcher flags must precede app arguments.** The split is positional: the first token the launcher does not recognize starts the inner arguments, so `--patch` placed after an app flag belongs to the app. The launcher's parser consumes one `--`, so an app argument that must survive as a literal `--` needs `-- --`. -- **A startup service has no declared owner.** The rows name it and an entrypoint provides it; nothing links the two statically, so a bundle that ships reading rows without its entrypoint fails at settlement (pending entries naming the service) rather than at load. +- **A startup service has no declared owner.** Reading rows name it and a `cmdlineArgs` consumer provides it; nothing links those two injections statically, so a bundle that ships reading rows without its startup row fails at settlement (pending entries naming the service) rather than at load. - **A user patch that replaces a row's whole `config` drops its expressions.** A flag beats the value written beside it, not a literal a user wrote in place of the expression; keeping the expression is what keeps the flag winning. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index 365a2c7f3c..76a76ad609 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -14,9 +14,9 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属 没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 -## 入口点,以及它的应用所读取的服务 +## 启动行,以及它的应用所读取的服务 -应用从自己的**入口点行**读取这些参数:入口点行是一个注入 `cmdlineArgs` 并调用 `runStartup(ctx, service, program, plan)` 的插件: +应用从自己的**启动行**读取这些参数:这是一个在 Loader 行与插件中都注入 `cmdlineArgs`,并调用 `runStartup(ctx, service, program, plan)` 的插件: ```ts ignore export const name = 'web-startup' @@ -27,13 +27,17 @@ export function apply(ctx: Context): void { } ``` -组合包的 `package.json` 点名那一行,这正是 boot 先于其他一切挂载它的依据: +Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字段: -```json -{ "dsh": { "bundle": { "patch": "./cordis.patch.yml", "entrypoint": "web-startup" } } } +```yaml +- id: web-startup + name: '@deepseek-ai/dsh-web-app/startup' + inject: [cmdlineArgs] ``` -应用用 flag 配置的每一行随后读取入口点解析出的取值,各自点名自己取用的键,以及回退时使用的值: +启动器在组合结果中找出带有该注入的活跃行,并先于其他一切挂载它们。 + +应用用 flag 配置的每一行随后读取启动行解析出的取值,各自点名自己取用的键,以及回退时使用的值: ```yaml - id: webserver @@ -50,13 +54,13 @@ export function apply(ctx: Context): void { ### 为什么 boot 分阶段 -行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答。因此一套组合分两趟挂载:先是各入口点,然后才是其余部分——这正是 manifest(元数据清单)声明所换来的东西。后一趟的行读到的是活的取值,`--help` 在第二趟存在之前就退出,而用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新运行,因此 flag 不会被悄悄重置。 +行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答。因此一套组合分两趟挂载:先是各个活跃的 `cmdlineArgs` 消费方,然后才是其余部分。后一趟的行读到的是活的取值,`--help` 在第二趟存在之前就退出,而用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新运行,因此 flag 不会被悄悄重置。 -`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。要从与被启用行同一趟挂载的行里调用它,而不是从入口点:在第一趟被启用的行会去等待第二趟才挂载的服务。 +`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。要从与被启用行同一趟挂载的行里调用它,而不是从启动行:在第一趟被启用的行会去等待第二趟才挂载的服务。 ### 一条命令行,一个所有者 -一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的入口点行,并同时点名两个服务,使它吸收过来的行按各自回退值启动:[`dsh-headless`](../../bundle/headless/README.md) 相对 [`dsh-web-app`](../../bundle/web-app/README.md) 就是这么做的。 +一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的启动行,并同时点名两个服务,使它吸收过来的行按各自回退值启动:[`dsh-headless`](../../bundle/headless/README.md) 相对 [`dsh-web-app`](../../bundle/web-app/README.md) 就是这么做的。 树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。 @@ -71,5 +75,5 @@ export function apply(ctx: Context): void { ## 已知限制与延期工作 - **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。 -- **启动服务没有声明所有者**:各行点名它,由入口点提供它;两者之间没有静态关联,因此交付了读取行却缺少对应入口点的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。 +- **启动服务没有声明所有者**:读取行点名它,由 `cmdlineArgs` 消费方提供它;这两种注入之间没有静态关联,因此交付了读取行却缺少对应启动行的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。 - **用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉**:flag 胜过的是表达式旁写着的那个值,而不是用户用字面量替换掉表达式之后的结果;保留表达式才能保留 flag 的优先级。 diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 4e3953bd1b..7d1a93f71d 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-cmdline", - "description": "Command-line seam between a dsh launcher and surface bundles: the cmdlineArgs service exposing the invocation's inner arguments, the startup host for contributing flag-derived config patches, and the commander adapter startup plugins share", + "description": "Command-line seam between a dsh launcher and app bundles: cmdlineArgs exposes inner arguments, while injected startup rows parse them into app-owned runtime services", "version": "0.0.1", "private": true, "type": "module", @@ -28,7 +28,6 @@ "commander": "^15.0.0" }, "peerDependencies": { - "@deepseek-ai/cordis-plugin-include": "^1.0.4", "@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" diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index 43c3b9ec58..f927a21072 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -10,14 +10,12 @@ * An app consumes those arguments from a **startup plugin**: a row that * injects `cmdlineArgs` and calls {@link runStartup}. What that plugin resolves * becomes its own service, and the rows it configures read the values from - * there — `port: !!js ctx.get('webStartup')?.port ?? 3080` — so a flag beats + * there — `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats * the value written beside it. Nothing is handed back to the launcher. * - * Those rows ship `disabled: true`, because a row's config is resolved when the - * Loader creates its fiber and a strict `ctx.get` only sees a service whose - * providing fiber is already active. The startup plugin enables them once its - * own fiber is active, and keeps them enabled when a recomposition of the tree - * puts them back. + * Loader delays each row's config interpolation until its declared injections + * are active. A startup row consumes `cmdlineArgs`, provides the app's resolved + * values, and thereby activates only the rows that depend on those values. * @module @deepseek-ai/dsh-cmdline */ @@ -70,10 +68,9 @@ export interface CmdlineHost { * Settles when the launcher has finished mounting, which a row that * publishes readiness (a URL line a supervisor waits for) must await. * - * A boot mounts in phases, so Loader settlement no longer means the whole - * composition is up: a row mounted in a later phase can observe a settled - * tree while rows beside it have yet to mount, or while the phase that - * mounted it is already rolling back. Rejects with the boot failure. + * Loader mounts sibling rows concurrently, so one row can become active + * while another is still mounting or while the whole boot is rolling back. + * Rejects with the boot failure. */ ready?: Promise<void> } @@ -92,6 +89,20 @@ export function provideCmdline(ctx: Context, host: CmdlineHost): void { if (host.ready !== undefined) ctx.provide('appReady', host.ready) } +/** + * Detect whether an active row consumes the launcher's command line. + * + * The Loader-row injection is the declaration: an active row that names + * `cmdlineArgs` owns startup for this composition. No bundle manifest field or + * plugin import is needed, so an out-of-tree app adds its command line by + * adding the same injection its startup plugin already requires. + * @param rows - the composed Loader rows. + * @returns whether this composition has a command-line owner. + */ +export function hasCmdlineConsumer(rows: readonly EntryOptions[]): boolean { + return rows.some(row => row.disabled !== true && waitsForAny(row.inject, ['cmdlineArgs'])) +} + /** The process streams commander output is written to; production writes to the process. */ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { write(chunk: string): unknown } } = { stdout: process.stdout, @@ -107,26 +118,26 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w * to reject the invocation with a usage message instead of throwing. * @param program - the parsed commander program. * @param rows - the waiting rows' composed options, in tree order. + * @param ctx - the startup row's context, for resolving composed fallbacks before the service exists. * @returns the service value the app's rows read; `undefined` keys let a row's * own fallback stand. */ -export type StartupPlan<T = unknown> = (program: Command, rows: readonly EntryOptions[]) => T +export type StartupPlan<T = unknown> = (program: Command, rows: readonly EntryOptions[], ctx: Context) => T /** * Run one app's startup: parse the invocation's inner arguments with the app's - * own commander program, provide the resolved values as `service`, and start - * the rows that were waiting for it. + * own commander program and provide the resolved values as `service`. The + * Loader then activates the rows that were waiting for the provided service. * * The rows read their values from the service, so nothing is written into - * their config from here: a row asks for `ctx.get('<service>')?.<key>` and - * falls back to the value written beside it, which is why a flag wins. They are - * enabled from inside an injection on the service itself, because a strict - * `ctx.get` only resolves a service whose providing fiber is already active, - * and re-enabled whenever a recomposition of the tree disables them again — a - * user editing a live patch file must not take the app down. + * their config from here: a row asks for `ctx.<service>.<key>` and + * falls back to the value written beside it, which is why a flag wins. Loader + * resolves a row's config only after its injections are active. A live + * recomposition reads the service that remains active, so editing a user patch + * cannot reset an invocation value. * * Help, version, and rejected arguments are terminal for the process: the text - * is written, the service is never provided, the app's rows stay disabled, and + * is written, the service is never provided, dependent rows stay pending, and * `ctx.appExit` is requested. * * An app that layers over another one (the one-shot bundle rides over the web @@ -171,7 +182,7 @@ export function runStartup<T>( // and nothing to start, and the check below would blame the bundle for a // tree that simply went away. if (ctx.get('loader') === undefined) return undefined - values = plan(program, waitingRows(ctx, names)) + values = plan(program, waitingRows(ctx, names), ctx) } catch (error) { // exitOverride turns help, version, a parse error, and a plan's own // program.error() into a CommanderError; commander has already written the @@ -191,17 +202,16 @@ export function runStartup<T>( * * A row cannot be inserted from inside a mounting plugin — the Loader returns a * prefixed id it then fails to resolve — so a conditional row ships disabled - * and an entrypoint enables it. - * Call it from a row that mounts alongside the one being enabled: an - * entrypoint runs before the rest of the composition, so a row it enabled - * there would wait for services that have yet to mount. + * and a row mounted beside it enables it after startup resolves the invocation. * @param ctx - plugin context whose Loader tree carries the row. * @param id - the row id. - * @returns nothing once the row has started. - * @throws when the composition has no row with that id. + * @returns nothing once the row has started or is waiting for its dependencies. + * @throws when the Loader or named row is absent. */ export async function enableRow(ctx: Context, id: string): Promise<void> { - const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === id) + const loader = ctx.get('loader') + if (loader === undefined) throw new Error('dsh-cmdline: enabling a row requires the Loader service') + const entry = [...loader.entries()].find(candidate => candidate.options.id === id) if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`) await entry.update({ disabled: false }) } diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index ee405bb135..d724fe9531 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -1,8 +1,7 @@ /** * The launcher-to-app command line over a REAL Loader tree, mounted the way a - * profile boot mounts it: the entrypoint row first, then the rest of the - * composition, whose rows read the entrypoint's values from their own config - * expressions. `--help` never reaches that second phase. + * profile boot mounts it: Loader holds each row until its injections are + * active, then resolves that row's config against its injection-ready context. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -15,7 +14,9 @@ import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import type { PatchOptions } from '@cordisjs/plugin-include' import { afterEach, describe, expect, it } from 'vitest' -import { internals, provideCmdline, runStartup, type StartupPlan } from '../src/index.ts' +import { + enableRow, hasCmdlineConsumer, internals, provideCmdline, runStartup, type StartupPlan, +} from '../src/index.ts' /** Every value one boot of the fixture tree observed. */ interface Observed { @@ -56,8 +57,8 @@ const demoPlan: StartupPlan<{ port?: number }> = (program) => { const expression = (source: string): unknown => ({ __jsExpr: source }) /** - * Mount a two-row composition the way a profile boot does: the entrypoint row - * alone first, then everything. + * Mount a two-row composition the way a profile boot does: both rows at once, + * with Loader ordering config resolution from their injections. * @param args - the invocation's inner arguments. * @param plan - the app's plan; defaults to the fixture's own. * @returns the booted fixture. @@ -65,7 +66,7 @@ const expression = (source: string): unknown => ({ __jsExpr: source }) async function bootFixture( args: string[], plan: StartupPlan = demoPlan, - options: { withoutEntrypoint?: boolean } = {}, + options: { objectInject?: boolean; withoutStartup?: boolean } = {}, ): Promise<Fixture> { const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) const observed: Observed = { exits: [], out: '' } @@ -77,7 +78,7 @@ export function apply(ctx, config) { globalThis.__observed.started = config } // The Loader imports a row through Node's own resolver, which cannot resolve // this workspace's sources; the row delegates to the real function the test // imported through the source-plane path mapping. - writeFileSync(join(dir, 'entrypoint.mjs'), ` + writeFileSync(join(dir, 'startup.mjs'), ` export const name = 'demo-startup' export const inject = ['cmdlineArgs'] export function apply(ctx) { return globalThis.__runStartup(ctx) } @@ -94,14 +95,14 @@ export function apply(ctx) { return globalThis.__runStartup(ctx) } // config carries `!!js` expressions. const composition: PatchOptions[] = [{ insert: [ - ...options.withoutEntrypoint === true + ...options.withoutStartup === true ? [] - : [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'entrypoint.mjs')).href }], + : [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'startup.mjs')).href, inject: ['cmdlineArgs'] }], { id: 'reader', name: pathToFileURL(join(dir, 'reader.mjs')).href, - inject: ['demoStartup'], - config: { port: expression("ctx.get('demoStartup')?.port ?? 3080") }, + inject: options.objectInject === true ? { demoStartup: { required: true } } : ['demoStartup'], + config: { port: expression('ctx.demoStartup?.port ?? 3080') }, }, ], }] @@ -109,22 +110,29 @@ export function apply(ctx) { return globalThis.__runStartup(ctx) } await ctx.plugin(Loader) ctx.loader.builtins.include = Include provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) - const rootConfig = { path: pathToFileURL(join(dir, 'cordis.yml')).href } - // Phase one: the entrypoint alone. - const includeId = await ctx.loader.create({ + await ctx.loader.create({ name: 'cordis:include', - config: { ...rootConfig, patches: [...structuredClone(composition), { id: 'reader', disabled: true }] }, + config: { path: pathToFileURL(join(dir, 'cordis.yml')).href, patches: structuredClone(composition) }, }) await ctx.loader.await() disposers.push(async () => { await ctx.fiber.dispose() }) - if (observed.exits.length === 0) { - // Phase two: the whole composition, now that the entrypoint's values answer. - await ctx.loader.resolve(includeId).update({ config: { ...rootConfig, patches: structuredClone(composition) } }) - await ctx.loader.await() - } return { observed, ctx } } +describe('hasCmdlineConsumer', () => { + it('recognizes active array and object injections', () => { + expect(hasCmdlineConsumer([ + { id: 'ordinary', name: 'ordinary' }, + { id: 'disabled-startup', name: 'disabled-startup', inject: ['cmdlineArgs'], disabled: true }, + { id: 'tui-startup', name: 'tui-startup', inject: { cmdlineArgs: { required: true } } }, + ])).toBe(true) + expect(hasCmdlineConsumer([ + { id: 'ordinary', name: 'ordinary' }, + { id: 'disabled-startup', name: 'disabled-startup', inject: ['cmdlineArgs'], disabled: true }, + ])).toBe(false) + }) +}) + describe('runStartup', () => { it('lets a row read the flag value the app resolved', async () => { const { observed } = await bootFixture(['--port', '8080']) @@ -137,6 +145,11 @@ describe('runStartup', () => { expect(observed.started).toEqual({ port: 3080 }) }) + it('recognizes the Loader object form of a startup-service injection', async () => { + const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true }) + expect(observed.started).toEqual({ port: 8080 }) + }) + it('prints the app help, starts no reading row, and requests exit 0', async () => { const { observed } = await bootFixture(['--help']) expect(observed.out).toContain('Usage: demo') @@ -152,13 +165,13 @@ describe('runStartup', () => { }) it('rethrows a plan failure that is not commander asking to exit', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) + const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) const plan: StartupPlan = () => { throw new Error('plan exploded') } expect(() => { runStartup(ctx, 'demoStartup', demoCommand(), plan) }).toThrow('plan exploded') }) it('rethrows a thrown value that is not an object at all', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) + const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) const plan: StartupPlan = () => { const thrown: unknown = 'plan threw a string' throw thrown @@ -167,36 +180,57 @@ describe('runStartup', () => { }) it('fails loud when no row injects the service the app provides', async () => { - // The bundle patch and its entrypoint disagree; a silent no-op would leave + // The bundle patch and its startup row disagree; a silent no-op would leave // every row of the app on its fallbacks with no explanation. - const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) + const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) expect(() => { runStartup(ctx, 'absentStartup', demoCommand()) }) .toThrow('absentStartup: no row injects this startup service') }) it('provides an empty value when the app declares no plan', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) + const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) runStartup(ctx, 'demoStartup', demoCommand()) expect(ctx.get('demoStartup')).toEqual({}) }) }) +describe('enableRow', () => { + it('enables the named Loader row and fails loud when the Loader or row is absent', async () => { + const withoutLoader = new Context() + await expect(enableRow(withoutLoader, 'client-hmr')).rejects.toThrow('requires the Loader service') + + const ctx = new Context() + let update: unknown + ctx.provide('loader', { + entries: () => [{ + options: { id: 'client-hmr' }, + update: async (options: unknown) => { update = options }, + }], + } as never) + await enableRow(ctx, 'client-hmr') + expect(update).toEqual({ disabled: false }) + await expect(enableRow(ctx, 'absent')).rejects.toThrow('no "absent" row to enable') + }) +}) + describe('provideCmdline', () => { it('hands the app a snapshot the caller cannot mutate afterwards', () => { const ctx = new Context() const args = ['--resume', 'abc'] - provideCmdline(ctx, { args, exit: () => {} }) + const ready = Promise.resolve() + provideCmdline(ctx, { args, exit: () => {}, ready }) args.push('--tampered') expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) + expect(ctx.appReady).toBe(ready) }) - it('fails loud when an entrypoint runs without the launcher values', () => { + it('fails loud when a startup row runs without the launcher values', () => { const ctx = new Context() expect(() => { runStartup(ctx, 'demoStartup', demoCommand()) }) .toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit') }) - it('resolves nothing when the tree was disposed while the entrypoint parsed', () => { + it('resolves nothing when the tree was disposed while the startup row parsed', () => { // An early SIGTERM takes the Loader with it; there is nothing left to // configure, and the bundle did nothing wrong. const exits: number[] = [] diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index eb8a2289cd..abe5a95e0d 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -1,8 +1,8 @@ # The dsh-headless bundle patch: one-shot task mode directly over dsh-base. # It mounts no Host, HTTP server, Web runtime, or browser plugin. The startup -# row owns the task positional (`dsh --profile headless "<task>"`) and this -# app's --help; the direct driver creates an Agent through the core registry -# and prints the final durable assistant message. +# row injects `cmdlineArgs`, owns the task positional +# (`dsh --profile headless "<task>"`) and this app's --help; the direct driver +# creates an Agent through the core registry and prints its durable result. - id: system-prompt config: @@ -25,6 +25,7 @@ - id: headless-startup name: '@deepseek-ai/dsh-headless/startup' + inject: [cmdlineArgs] # Reads its task from the headlessStartup service after the startup row # resolves this app's command line. diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 5d2af07463..e439fe75f7 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -33,8 +33,7 @@ "license": "BSD-3-Clause", "dsh": { "bundle": { - "patch": "./cordis.patch.yml", - "entrypoint": "headless-startup" + "patch": "./cordis.patch.yml" } }, "dependencies": { @@ -50,7 +49,6 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-web-app": "^0.0.1", "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -60,7 +58,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-web-app": "workspace:^", "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index 0f613aae08..74f9dfb6b1 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -4,11 +4,6 @@ * `--help` text, then provides {@link HEADLESS_STARTUP_SERVICE} with the task * the user asked for. The runner waits for it, so a missing task is a usage * error printed by this command instead of a schema failure inside the runner. - * - * This app layers over the web app, and a composition has exactly one - * command-line owner: the bundle patch disables the web startup row, and this - * one also provides {@link WEB_STARTUP_SERVICE} so the web rows start on their - * composed (one-shot) values. * @module @deepseek-ai/dsh-headless/startup */ @@ -16,7 +11,6 @@ import { Command } from 'commander' import type { Context } from 'cordis' import type { EntryOptions } from '@cordisjs/plugin-loader' import { runStartup } from '@deepseek-ai/dsh-cmdline' -import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup' /** Stable Cordis plugin name. */ export const name = 'headless-startup' @@ -75,5 +69,5 @@ function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): H * @returns nothing once the runner is started, or once `--help` or a missing task requested exit. */ export function apply(ctx: Context): void { - runStartup(ctx, [HEADLESS_STARTUP_SERVICE, WEB_STARTUP_SERVICE], headlessCommand(), planHeadlessStartup) + runStartup(ctx, HEADLESS_STARTUP_SERVICE, headlessCommand(), planHeadlessStartup) } diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index fc908306e3..651ef0aebb 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -1,5 +1,5 @@ /** - * The one-shot app's entrypoint row over a REAL Loader tree: the task + * The one-shot app's startup row over a REAL Loader tree: the task * positional becomes the value the runner row reads, a missing task is a usage * error, and the web service this app absorbs is provided too, so the web rows * it rides over resolve on their own fallbacks. @@ -32,7 +32,7 @@ afterEach(async () => { }) /** - * Mount the real entrypoint row over stand-ins for the runner row and one web + * Mount the real startup row over stand-ins for the runner row and one web * row this app absorbs, the way a profile mounts phase one. * @param args - the invocation's inner arguments. * @param options - fixture knobs for the shapes a composition can take. @@ -48,7 +48,7 @@ async function bootStartup( // The Loader imports a row through Node's own resolver, which cannot resolve // this workspace's sources; the row delegates to the real plugin the test // imported through the source-plane path mapping. - writeFileSync(join(dir, 'entrypoint.mjs'), ` + writeFileSync(join(dir, 'startup.mjs'), ` export const name = 'headless-startup' export const inject = ['cmdlineArgs'] export const apply = ctx => globalThis.__headlessStartupApply(ctx) @@ -56,7 +56,7 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href writeFileSync(join(dir, 'cordis.yml'), [ // A composition that lost the runner still injects the service, so the - // entrypoint reaches its own row check rather than the generic one. + // startup row reaches its own row check rather than the generic one. options.withoutRunner === true ? '- id: displaced-runner' : '- id: headless-runner', ` name: ${rowUrl}`, ` inject: [${HEADLESS_STARTUP_SERVICE}]`, @@ -66,7 +66,8 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) ` inject: [${WEB_STARTUP_SERVICE}]`, ' disabled: true', '- id: headless-startup', - ` name: ${pathToFileURL(join(dir, 'entrypoint.mjs')).href}`, + ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, + ' inject: [cmdlineArgs]', '', ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index bc410b698b..7a86cb1df0 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -7,11 +7,11 @@ # # Rows this app configures from flags read them from the `webStartup` service: # each names the key it takes and the value it falls back to, so a flag wins -# over the value written beside it. The web-startup row is this bundle's -# manifest-declared entrypoint, so it runs before any of them and has already -# parsed --host/--port/--dev/--workspace-root/--trusted-host by the time their -# config is resolved. `dsh --profile web --help` therefore prints this app's own -# help and exits before the rest of the composition mounts at all. +# over the value written beside it. The web-startup row injects `cmdlineArgs`, +# so the launcher runs it first; it has parsed --host/--port/--dev/ +# --workspace-root/--trusted-host by the time those configs resolve. +# `dsh --profile web --help` therefore prints this app's own help and exits +# before the rest of the composition mounts at all. # ── surface-specific values the base deliberately omits ───────────────────── @@ -85,11 +85,12 @@ config: workspaceRoot: !!js ctx.get('webStartup')?.workspaceRoot - # This bundle's entrypoint (declared in its package.json): it owns the web - # flag family and its --help, and provides webStartup with the values this - # invocation resolved. The boot runs it before every row above. + # This app's command-line startup row: its `cmdlineArgs` injection makes the + # launcher mount it first. It owns the web flag family and its --help, and + # provides webStartup with the values this invocation resolved. - id: web-startup name: '@deepseek-ai/dsh-web-app/startup' + inject: [cmdlineArgs] # ── layer 2: transport/service ────────────────────────────────────────────── diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 0e2eff0e3b..e8240e1b63 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -33,8 +33,7 @@ "license": "BSD-3-Clause", "dsh": { "bundle": { - "patch": "./cordis.patch.yml", - "entrypoint": "web-startup" + "patch": "./cordis.patch.yml" } }, "dependencies": { diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index abf2ac4ca3..27b9a4e27a 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -4,15 +4,17 @@ * manifest field). The plugin owns the browser-surface glue: it resolves * the built frontend dist (workspace knowledge of this bundle, never user * config), mounts the `frontend-static` fallback owner over it, registers the - * web-surface prompt section and the bash-visible web runtime variables, and - * prints the URL line when configured to. Flag-derived values (`mode`, - * `lanAddresses`, `printUrl`) arrive as launcher patches over this row. + * harness-source and web-surface prompt sections, the bash-visible web runtime + * variables, and the URL line. App command-line values arrive through the + * `webStartup` service expressions in the bundle patch. * @module @deepseek-ai/dsh-web-app */ import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' import { enableRow } from '@deepseek-ai/dsh-cmdline' import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static' import type {} from '@deepseek-ai/cordis-plugin-loader' @@ -26,13 +28,16 @@ export const name = 'web-app' /** The client-plugin reload chain row this bundle ships disabled, for `--dev`. */ const HMR_ROW_ID = 'client-hmr' +/** This dsh installation's root, from either this package's source or built entry. */ +const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) + /** Services required before the web runtime can mount. */ export const inject = ['httpServer'] /** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ export type WebMode = 'production' | 'development' -/** Plugin config: the surface facts the launcher patches over this bundle's defaults. */ +/** Plugin config: composed deployment settings plus per-invocation startup values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode @@ -46,7 +51,7 @@ export interface Config { */ surfaceContext: boolean /** - * LAN IPv4 addresses sampled once by the launcher when the effective bind + * LAN IPv4 addresses sampled once by the app startup row when the effective bind * is all-interfaces — the exact snapshot the /api trust fence was * configured with, so the printed LAN URL can never name an address the * fence rejects. Empty on a loopback bind. @@ -113,16 +118,17 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex * variables, and the URL line. * @param ctx - plugin context carrying the httpServer service. * @param config - validated {@link Config}. + * @returns nothing once optional development rows are active and runtime contributions are registered. */ -export function apply(ctx: Context, config: Config): void { +export async function apply(ctx: Context, config: Config): Promise<void> { ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) // The client-plugin reload chain is a row this bundle ships off, because it // exists only in development. Turning it on belongs here rather than in the - // entrypoint: it needs the host rows this phase of the boot mounts, and the - // entrypoint runs before them. - if (config.mode === 'development') void enableRow(ctx, HMR_ROW_ID) + // startup row: it needs host services that also activate after webStartup. + if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID) if (config.surfaceContext) { ctx.inject(['systemPrompt'], (promptCtx) => { + addHarnessSourceSection(promptCtx, SOURCE_ROOT) promptCtx.systemPrompt.section({ name: 'app:web-surface', order: -98, @@ -146,16 +152,15 @@ export function apply(ctx: Context, config: Config): void { // sibling rows (the /api route owner) are still mounting. Await Loader // settlement first; a hand-built tree without a Loader prints at once. const printUrl = (): void => { - // The launcher's boot-time LAN snapshot, not a fresh sample: the printed + // The startup row's boot-time LAN snapshot, not a fresh sample: the printed // LAN URL must name an address the /api trust fence was configured with. const lanCandidate = config.lanAddresses[0] const port = ctx.httpServer.port console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) } - // A launcher that mounts in phases tells this row when the whole - // composition is up; Loader settlement alone would let the line print - // between phases, announcing a server whose boot can still fail. A - // hand-built tree has neither and prints at once. + // A launcher tells this row when the whole concurrent composition is up; + // this row's own activation can precede a sibling failure. A hand-built + // tree falls back to Loader settlement, or prints at once without Loader. const settled = ctx.get('appReady') ?? ctx.get('loader')?.await() if (settled === undefined) printUrl() else { diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index 7ac0f5192c..83def845cd 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -1,5 +1,5 @@ /** - * The web app's entrypoint row over a REAL Loader tree: every flag lands in the + * The web app's startup row over a REAL Loader tree: every flag lands in the * `webStartup` service the web rows read, the bind it reports comes from the * flag or from what the composition falls back to, `--help` resolves nothing, * and a rejected argument exits without resolving anything. @@ -39,7 +39,7 @@ afterEach(async () => { }) /** - * Mount the real entrypoint row over a stand-in for the `webserver` row whose + * Mount the real startup row over a stand-in for the `webserver` row whose * composed bind it reads, the way a profile mounts phase one. * @param args - the invocation's inner arguments. * @param webserverConfig - the composed `webserver` row config, or `null` to omit the row. @@ -55,7 +55,7 @@ async function bootStartup( // The Loader imports a row through Node's own resolver, which cannot resolve // this workspace's sources; the row delegates to the real plugin the test // imported through the source-plane path mapping. - writeFileSync(join(dir, 'entrypoint.mjs'), ` + writeFileSync(join(dir, 'startup.mjs'), ` export const name = 'web-startup' export const inject = ['cmdlineArgs'] export const apply = ctx => globalThis.__webStartupApply(ctx) @@ -82,7 +82,8 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) ` inject: [${WEB_STARTUP_SERVICE}]`, ' disabled: true', '- id: web-startup', - ` name: ${pathToFileURL(join(dir, 'entrypoint.mjs')).href}`, + ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, + ' inject: [cmdlineArgs]', '', ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } @@ -155,7 +156,7 @@ describe('web startup', () => { }) it('fails the boot when the composition lost the row whose bind it reads', async () => { - // The bundle patch and this entrypoint must agree on the row set; a + // The bundle patch and this startup row must agree on the row set; a // missing row would otherwise silently drop the flag that targets it. await expect(bootStartup([], null)) .rejects.toThrow('the web composition has no waiting "webserver" row to configure') diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index ab56e87db4..1962710b5e 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -2,7 +2,7 @@ * Web runtime glue behavior: dist resolution through the bundle's own hook, * the frontend-static child claiming the fallback seat, the web-surface * prompt section and bash runtime variables, and URL-line printing with the - * launcher's LAN snapshot. + * app startup row's LAN snapshot. */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -68,15 +68,25 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) + const hmrUpdates: unknown[] = [] + ctx.provide('loader', { + entries: () => [{ + options: { id: 'client-hmr' }, + update: async (options: unknown) => { hmrUpdates.push(options) }, + }], + await: async () => {}, + } as never) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] })) + await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] })) await ctx.plugin(SystemPrompt, { persona: '' }) // Settle the injected registrations. await new Promise(resolve => setTimeout(resolve, 0)) expect(seat()).toBeDefined() // frontend-static claimed the fallback + expect(hmrUpdates).toEqual([{ disabled: false }]) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)') const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') const section = assembly.sections.find(entry => entry.name === 'app:web-surface') expect(section?.text).toContain('http://127.0.0.1:4567') expect(section?.text).toContain('--dev') @@ -90,7 +100,7 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() @@ -111,11 +121,12 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) - apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false) + expect(assembly.sections.some(entry => entry.name === 'harness:source')).toBe(false) expect(contributions).toEqual([]) await ctx.fiber.dispose() }) @@ -125,23 +136,23 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await ctx.fiber.dispose() }) - it('waits for the launcher readiness the phased boot provides, and stays quiet when that boot failed', async () => { + it('waits for launcher readiness and stays quiet when the whole boot failed', async () => { stageDist() - // The launcher-provided readiness wins over Loader settlement: a phased - // boot settles the Loader between phases, long before the app is up. + // Launcher readiness covers siblings that may still be mounting after + // this row itself has activated. const ready = new Context() ready.provide('httpServer', fakeHttpServer().server) ready.provide('loader', { await: () => Promise.resolve() } as never) let announce: () => void ready.provide('appReady', new Promise<void>((resolve) => { announce = resolve })) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ready, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(ready, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() announce!() @@ -157,7 +168,7 @@ describe('web-app runtime glue', () => { const rejection = Promise.reject(new Error('boot failed')) rejection.catch(() => {}) failed.provide('appReady', rejection) - apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() await failed.fiber.dispose() @@ -173,7 +184,7 @@ describe('web-app runtime glue', () => { const settlement = new Promise<void>((resolve) => { release = resolve }) settled.provide('loader', { await: () => settlement } as never) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() release!() @@ -192,7 +203,7 @@ describe('web-app runtime glue', () => { let releaseTorn: () => void const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve }) torn.provide('loader', { await: () => tornSettlement } as never) - apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await child.dispose() // the httpServer service goes away releaseTorn!() await new Promise(resolve => setTimeout(resolve, 0)) @@ -208,7 +219,7 @@ describe('web-app runtime glue', () => { const { server } = fakeHttpServer() Object.defineProperty(server, 'port', { get: () => undefined }) ctx.provide('httpServer', server) - apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ae88ddf96..1dc47e50d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1483,9 +1483,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-web-app': - specifier: workspace:^ - version: link:../web-app packages/bundle/web-app: dependencies: From 7e3a82eacc5ba8206a70ab187ddea882f8030f76 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 7 Aug 2026 17:27:38 +0800 Subject: [PATCH 160/229] refactor(loader): resolve config after injected services --- ...026-08-05-profile-plugin-bundles.i18n.yaml | 4 +- .../2026-08-05-profile-plugin-bundles.md | 6 +- .../2026-08-05-profile-plugin-bundles.zh.md | 6 +- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 4 +- .../2026-08-06-app-owned-command-line.zh.md | 4 +- docs/cordis-api/fiber.i18n.yaml | 4 +- docs/cordis-api/fiber.md | 24 ++-- docs/cordis-api/fiber.zh.md | 24 ++-- docs/cordis-primer.i18n.yaml | 4 +- docs/cordis-primer.md | 2 +- docs/cordis-primer.zh.md | 2 +- packages/boot/app-boot/src/index.ts | 27 +--- packages/boot/app-boot/tests/app-boot.spec.ts | 34 +++++- .../boot/app-boot/tests/user-patches.spec.ts | 115 ++++++++++-------- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 16 +-- packages/boot/cmdline/README.zh.md | 16 +-- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 2 +- packages/bundle/headless/README.zh.md | 2 +- packages/bundle/headless/cordis.patch.yml | 2 +- packages/bundle/headless/src/startup.ts | 2 +- .../bundle/headless/tests/startup.spec.ts | 58 ++++----- packages/bundle/web-app/cordis.patch.yml | 14 +-- packages/bundle/web-app/src/startup.ts | 52 ++++++-- packages/bundle/web-app/tests/startup.spec.ts | 51 ++++++-- scripts/test-invariants.spec.ts | 75 +++--------- scripts/test-invariants.ts | 25 ++-- vendor/README.md | 3 +- vendor/cordis/src/events.ts | 6 + vendor/cordis/src/fiber.ts | 31 +++-- vendor/hmr/src/index.ts | 2 +- vendor/include/src/index.ts | 17 ++- vendor/loader/src/config/entry.ts | 35 +++--- vendor/loader/src/config/group.ts | 4 + vendor/loader/src/config/tree.ts | 2 +- vendor/loader/src/index.ts | 23 +++- 38 files changed, 404 insertions(+), 306 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index 938e802716..baee9e065f 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 2924b3cb445064fd47d82bcc94ec8d77ded5721b -2026-08-05-profile-plugin-bundles.zh.md: b2287034010bcac1048bb385b2266f1bc75921da +2026-08-05-profile-plugin-bundles.md: 385977b2d085a39bcda89bca0fb6543f08e7a961 +2026-08-05-profile-plugin-bundles.zh.md: 22ed4100b97db3f7c48bf55688f1a78edb512add diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 2924b3cb44..385977b2d0 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -10,11 +10,9 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y ## Decision -Everything becomes a **profile**: a directory `$DSH_HOME/profiles/<name>` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. +Everything becomes a **profile**: a directory `$DSH_HOME/profiles/<name>` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](2026-08-06-app-owned-command-line.md). -The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). `dsh web` is the Web-flag alias for `--profile web`; `dsh run [--profile <name>] "task"` owns one-shot execution and defaults to the headless profile; generic `dsh --profile <name>` boots without a task. Patch overlays use `--patch`. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. - -The [`dsh run` command decision](../feature/2026-08-08-dsh-run-headless-command.md) owns the one-shot grammar; this note owns the profile composition it selects. +The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index b228703401..22ed4100b9 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -10,11 +10,9 @@ Status: implemented ## Decision -一切都变成 **profile**:即目录 `$DSH_HOME/profiles/<name>`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 +一切都变成 **profile**:即目录 `$DSH_HOME/profiles/<name>`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](2026-08-06-app-owned-command-line.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。 -随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。`dsh web` 是携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile <name>] "task"` 负责一次性执行,默认使用 headless profile;通用的 `dsh --profile <name>` 启动 profile 而不携带任务。patch overlay 使用 `--patch`。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 - -[`dsh run` 命令决策](../feature/2026-08-08-dsh-run-headless-command.md)负责一次性语法;本 Agent Note 负责该语法所选择的 profile 组合。 +随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index f38cc36d1e..728edb1ec0 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: e533338118f1b195589ed05ad972d1d4a55e610c -2026-08-06-app-owned-command-line.zh.md: 00f492629fd08383726e71ad7eea608df22fb772 +2026-08-06-app-owned-command-line.md: 269f9193e6cf7852ba9652c961bfdd309080ae0b +2026-08-06-app-owned-command-line.zh.md: 943932062983622267f28591dcc22ca2d12274e0 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index e533338118..269f9193e6 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -25,8 +25,8 @@ Two further consequences. Loader mounts sibling rows concurrently, so one row ca Four framework facts shape the mechanism: - **A profile's rows arrive inside the root include's `patches` option.** Include is an entry-tree owner, so its static entry-config resolver interpolates Include's own options while preserving nested `!!js` nodes for their target rows instead of recursively evaluating them in the Include context. -- **Cordis activates a fiber only after all declared injections are active.** Loader supplies a deferred config resolver to that fiber; the resolver runs immediately before each activation against the fiber's own context, after Cordis snapshots its injected services. -- **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the resolver, HMR carries it to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services. +- **Cordis activates a fiber only after all declared injections are active.** Immediately before each activation, Cordis runs the `internal/config` waterfall against the fiber's own context; Loader's listener interpolates the raw config after Cordis snapshots its injected services. +- **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the waterfall, HMR carries the raw config to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services. - **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and an active row enables it (`dsh web --dev` and its reload chain); the enabled row then follows ordinary injection ordering. This puts dependency ordering at the seam that owns it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index 00f492629f..9439320629 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -25,8 +25,8 @@ boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Lo 四条框架事实塑造了这套机制: - **profile 的各行位于根 include 的 `patches` 选项内部。** Include 是条目树所有者,因此它的静态条目配置解析器会插值 Include 自身的选项,同时为目标行保留嵌套的 `!!js` 节点,而不是在 Include 上下文中递归求值。 -- **Cordis 只在所有声明的注入都已激活后才激活 fiber。** Loader 为该 fiber 提供延迟配置解析器;Cordis 快照注入服务之后,解析器会在每次激活前一刻基于 fiber 自身上下文运行。 -- **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑解析器,HMR 会把它带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。 +- **Cordis 只在所有声明的注入都已激活后才激活 fiber。** 每次激活前一刻,Cordis 会基于 fiber 自身上下文运行 `internal/config` waterfall;Cordis 快照注入服务之后,Loader 的监听器再插值原始配置。 +- **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfall,HMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。 - **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路);启用后的行继续遵循普通注入顺序。 这样,依赖顺序就由真正持有它的接缝负责。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。 diff --git a/docs/cordis-api/fiber.i18n.yaml b/docs/cordis-api/fiber.i18n.yaml index 6c01366dc1..537be01dbc 100644 --- a/docs/cordis-api/fiber.i18n.yaml +++ b/docs/cordis-api/fiber.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-api/fiber.md -fiber.md: 36d2861ac6a53e8186a92d86c65ba228d4b59ee5 -fiber.zh.md: fafa559ca911677c43893862190009d82c39c56b +fiber.md: 182b77390b29b8a90504437d0ccc2dfeba23921a +fiber.zh.md: 9ed3e52618586dc3815b9d913439d11a227fb64b diff --git a/docs/cordis-api/fiber.md b/docs/cordis-api/fiber.md index 36d2861ac6..182b77390b 100644 --- a/docs/cordis-api/fiber.md +++ b/docs/cordis-api/fiber.md @@ -34,7 +34,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../vendor/cordis/src/fiber.ts#L420) +[Source](../../vendor/cordis/src/fiber.ts#L415) ### ctx.fiber @@ -97,7 +97,7 @@ public state Current lifecycle state; transitions emit `internal/status`. -[Source](../../vendor/cordis/src/fiber.ts#L192) +[Source](../../vendor/cordis/src/fiber.ts#L194) ### fiber.dispose @@ -108,7 +108,7 @@ public readonly dispose: () => Promise<void> Dispose this fiber: unload the plugin, then settle once cleanup finished. -[Source](../../vendor/cordis/src/fiber.ts#L194) +[Source](../../vendor/cordis/src/fiber.ts#L196) ### fiber.store @@ -119,7 +119,7 @@ public store: Dict<Impl> | undefined Snapshot of required service implementations while loaded; `undefined` otherwise. -[Source](../../vendor/cordis/src/fiber.ts#L196) +[Source](../../vendor/cordis/src/fiber.ts#L198) ### fiber.inertia @@ -130,7 +130,7 @@ public inertia: Promise<void> | undefined The in-flight load/unload transition, if one is currently running. -[Source](../../vendor/cordis/src/fiber.ts#L198) +[Source](../../vendor/cordis/src/fiber.ts#L200) ### fiber.name @@ -141,7 +141,7 @@ get name() The plugin's display name, inherited from the nearest named ancestor, else `'root'`. -[Source](../../vendor/cordis/src/fiber.ts#L341) +[Source](../../vendor/cordis/src/fiber.ts#L336) ### fiber.assertActive() @@ -159,7 +159,7 @@ Throw if the fiber has already been disposed. **Returns** nothing when the fiber is still active. -[Source](../../vendor/cordis/src/fiber.ts#L356) +[Source](../../vendor/cordis/src/fiber.ts#L351) ### fiber.effect(execute, label?) @@ -190,7 +190,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../vendor/cordis/src/fiber.ts#L420) +[Source](../../vendor/cordis/src/fiber.ts#L415) ### fiber.getEffects() @@ -207,7 +207,7 @@ Return metadata for currently registered effects. **Returns** one `EffectMeta` tree per labeled live effect. -[Source](../../vendor/cordis/src/fiber.ts#L573) +[Source](../../vendor/cordis/src/fiber.ts#L568) ### fiber.await() @@ -225,7 +225,7 @@ Wait for current lifecycle work and rethrow startup errors. **Returns** this fiber, once it has settled into a stable state. -[Source](../../vendor/cordis/src/fiber.ts#L702) +[Source](../../vendor/cordis/src/fiber.ts#L704) ### fiber.restart() @@ -243,7 +243,7 @@ Dispose and immediately reload this plugin with its current config. **Returns** a promise resolving once the reload settled. -[Source](../../vendor/cordis/src/fiber.ts#L716) +[Source](../../vendor/cordis/src/fiber.ts#L718) ### fiber.update(config, noSave?) @@ -271,7 +271,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o **Returns** the update waterfall result; the default restart returns a promise. -[Source](../../vendor/cordis/src/fiber.ts#L734) +[Source](../../vendor/cordis/src/fiber.ts#L736) ## Effect diff --git a/docs/cordis-api/fiber.zh.md b/docs/cordis-api/fiber.zh.md index fafa559ca9..9ed3e52618 100644 --- a/docs/cordis-api/fiber.zh.md +++ b/docs/cordis-api/fiber.zh.md @@ -36,7 +36,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>> **返回**一个用于撤销该作用的清理函数,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L420) +[源码](../../vendor/cordis/src/fiber.ts#L415) ### ctx.fiber @@ -99,7 +99,7 @@ public state 当前生命周期状态;状态转换会发出 `internal/status`。 -[源码](../../vendor/cordis/src/fiber.ts#L192) +[源码](../../vendor/cordis/src/fiber.ts#L194) ### fiber.dispose @@ -110,7 +110,7 @@ public readonly dispose: () => Promise<void> dispose 此 fiber:卸载插件,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L194) +[源码](../../vendor/cordis/src/fiber.ts#L196) ### fiber.store @@ -121,7 +121,7 @@ public store: Dict<Impl> | undefined 加载期间所需服务实现的快照;其他情况下为 `undefined`。 -[源码](../../vendor/cordis/src/fiber.ts#L196) +[源码](../../vendor/cordis/src/fiber.ts#L198) ### fiber.inertia @@ -132,7 +132,7 @@ public inertia: Promise<void> | undefined 当前正在进行的加载或卸载转换;如果没有此类转换,则为 undefined。 -[源码](../../vendor/cordis/src/fiber.ts#L198) +[源码](../../vendor/cordis/src/fiber.ts#L200) ### fiber.name @@ -143,7 +143,7 @@ get name() 插件的显示名称,继承自最近的具名祖先;如果不存在,则为 `'root'`。 -[源码](../../vendor/cordis/src/fiber.ts#L341) +[源码](../../vendor/cordis/src/fiber.ts#L336) ### fiber.assertActive() @@ -161,7 +161,7 @@ assertActive() **返回**:fiber 仍处于活动状态时不返回任何内容。 -[源码](../../vendor/cordis/src/fiber.ts#L356) +[源码](../../vendor/cordis/src/fiber.ts#L351) ### fiber.effect(execute, label?) @@ -192,7 +192,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>> **返回**一个用于撤销该作用的清理函数,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L420) +[源码](../../vendor/cordis/src/fiber.ts#L415) ### fiber.getEffects() @@ -209,7 +209,7 @@ getEffects() **返回**:每个带标签的活动作用对应一棵 `EffectMeta` 树。 -[源码](../../vendor/cordis/src/fiber.ts#L573) +[源码](../../vendor/cordis/src/fiber.ts#L568) ### fiber.await() @@ -227,7 +227,7 @@ async await() **返回**:进入稳定状态后的此 fiber。 -[源码](../../vendor/cordis/src/fiber.ts#L702) +[源码](../../vendor/cordis/src/fiber.ts#L704) ### fiber.restart() @@ -245,7 +245,7 @@ dispose 此插件,并立即使用其当前配置重新加载。 **返回**一个在重新加载完成后兑现的 promise。 -[源码](../../vendor/cordis/src/fiber.ts#L716) +[源码](../../vendor/cordis/src/fiber.ts#L718) ### fiber.update(config, noSave?) @@ -273,7 +273,7 @@ update(config: any, noSave = false) **返回**更新 waterfall 的结果;默认的重新启动操作返回一个 promise。 -[源码](../../vendor/cordis/src/fiber.ts#L734) +[源码](../../vendor/cordis/src/fiber.ts#L736) ## Effect diff --git a/docs/cordis-primer.i18n.yaml b/docs/cordis-primer.i18n.yaml index ad9cfe716e..180ba85c01 100644 --- a/docs/cordis-primer.i18n.yaml +++ b/docs/cordis-primer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-primer.md -cordis-primer.md: 93725949a9490f757edebcf3e8391db9e73321b1 -cordis-primer.zh.md: fd2a327b526b210986bc1574013fca2c0cec5dda +cordis-primer.md: d1e7c5fd8eaaa89fe448d238359389d945cd6346 +cordis-primer.zh.md: d6ce0f2024f65b006c9505daffaa06a08bb56875 diff --git a/docs/cordis-primer.md b/docs/cordis-primer.md index 93725949a9..d1e7c5fd8e 100644 --- a/docs/cordis-primer.md +++ b/docs/cordis-primer.md @@ -35,7 +35,7 @@ For single-decision events, short-circuiting is the design. A policy listener ca ## Loader Configuration -`@deepseek-ai/cordis-plugin-include` parses `!!js` into expression nodes, but the Loader interpolates only an entry's `config` before mounting the plugin. Entry metadata (`id`, `name`, `group`, `disabled`, `inject`, `intercept`, and `isolate`) remains literal; `disabled: !!js ...` is therefore a truthy object that always disables the entry. Use explicit config overlays when environment selection changes which plugins are mounted. +`@deepseek-ai/cordis-plugin-include` parses `!!js` into expression nodes. Loader interpolates only an entry's `config`, after declared injections activate, against that plugin context (`ctx.serviceName`); Include preserves nested row expressions until target activation. Entry metadata (`id`, `name`, `group`, `disabled`, `inject`, `intercept`, `isolate`) stays literal, so `disabled: !!js ...` always disables the entry. Use overlays when the environment selects plugins. ## Practical Rules diff --git a/docs/cordis-primer.zh.md b/docs/cordis-primer.zh.md index fd2a327b52..d6ce0f2024 100644 --- a/docs/cordis-primer.zh.md +++ b/docs/cordis-primer.zh.md @@ -39,7 +39,7 @@ Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。 ## Loader 配置 -`@deepseek-ai/cordis-plugin-include` 将 `!!js` 解析为表达式节点,但 Loader 仅在挂载插件前对条目的 `config` 做插值。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept` 和 `isolate`)保持字面值;因此 `disabled: !!js ...` 是一个 truthy 对象,会始终禁用该条目。需要根据环境选择挂载哪些插件时,请使用显式的配置覆盖层。 +`@deepseek-ai/cordis-plugin-include` 将 `!!js` 解析为表达式节点。Loader 只在声明的注入激活后,基于该插件上下文(`ctx.serviceName`)插值条目的 `config`;Include 会保留嵌套行表达式,直到目标行激活。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept`、`isolate`)保持字面值,因此 `disabled: !!js ...` 始终禁用该条目。由环境选择插件时,请使用 overlay。 ## 实践规则 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 5f8d6643a1..41274e4a62 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -199,7 +199,7 @@ export function loadLayeredEnv( const bootstrapIncludes = new WeakMap<Context, Entry>() // The include's YAML dialect (`!!js` scalars become expression nodes the -// Loader interpolates against each entry's context at mount time), imported +// Loader interpolates against each entry's injection-ready context), imported // from the include itself so patch parsing and config dumping can never drift // from what the include mounts. User patch layers share it so they may // reference `process.env`. @@ -527,31 +527,6 @@ export async function mountRootInclude( return entry } -/** - * Re-apply the root include's patch list on a booted tree, and wait for the - * result to settle. - * - * This is how a boot mounts its composition in phases: an app's startup row - * resolves what the rest of the tree reads (`!!js ctx.get('webStartup')?.port`), - * and a row's config expressions are evaluated when the include applies them — - * so the rest of the composition must be applied after the startup rows are - * active, not before. - * @param ctx - the booted context whose root include to re-apply. - * @param patches - the full patch list for this generation. - * @returns nothing once the new generation has settled; a disposed tree is a no-op. - * @throws when the tree was booted without the root include. - */ -export async function applyRootPatches(ctx: Context, patches: readonly PatchOptions[]): Promise<void> { - const entry = bootstrapIncludes.get(ctx) - if (entry === undefined) throw new Error('dsh: applying root patches requires the root Include entry') - // A surface can dispose the whole tree while a startup row is still parsing - // (`--help`, or an early SIGTERM); there is then nothing left to mount. - if (ctx.get('loader') === undefined) return - const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config - await entry.update({ config: { ...includeConfig, patches: [...patches] } }) - await ctx.get('loader')?.await() -} - /** * The slice of `process` {@link installFailLoud} needs — injectable so tests * exercise the handler without registering on (or exiting) the real process. diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 84c1748498..8bbb8fddfa 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' @@ -699,7 +699,18 @@ describe('boot', () => { '}', '', ].join('\n')) - writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n') + writeFileSync(join(dir, 'delayed.mjs'), [ + 'await new Promise(resolve => setTimeout(resolve, 10))', + 'export function apply() {}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: exiting', + ' name: ./exiting.mjs', + '- id: delayed', + ' name: ./delayed.mjs', + '', + ].join('\n')) const ctx = await boot(NAME, join(dir, 'cordis.yml')) expect(ctx.get('loader')).toBeUndefined() }) @@ -712,6 +723,25 @@ describe('boot', () => { ) }) + it('labels a deferred config failure with its row and leaves the source file unchanged', async () => { + const dir = tmp() + const configPath = join(dir, 'cordis.yml') + const config = [ + '- id: invalid-config', + ' name: ./noop.mjs', + ' config:', + ' value: !!js "JSON.parse(\'invalid\')"', + '', + ].join('\n') + writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n') + writeFileSync(configPath, config) + + await expect(boot(NAME, configPath)).rejects.toThrow( + 'failed to apply loader entry invalid-config (./noop.mjs)', + ) + expect(readFileSync(configPath, 'utf8')).toBe(config) + }) + it('appends the deepest cause with its original stack to the load failure', async () => { const dir = tmp() writeFileSync(join(dir, 'failing.mjs'), [ diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index a55d2d246f..da58524e1d 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -11,11 +11,10 @@ import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Hmr from '@deepseek-ai/cordis-plugin-hmr' +import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import Loader from '@deepseek-ai/cordis-plugin-loader' import Timer from '@deepseek-ai/cordis-plugin-timer' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { - applyRootPatches, boot, loadOptionalPatches, PROFILE_PATCH_FILENAME, @@ -110,61 +109,81 @@ function entryConfig(ctx: Context, id: string): unknown { return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config } -describe('applyRootPatches', () => { - it('mounts a later phase whose rows read what the first phase provided', async () => { - // The phased boot in one test: a row's `!!js` config is evaluated when the - // include applies it, so a value an earlier phase provided is what a later - // phase's rows read. +describe('Loader config interpolation', () => { + it("resolves Include's own !!js options", async () => { const dir = tmp() - writeFileSync(join(dir, 'provider.mjs'), [ - 'export const name = "provider"', - 'export function apply(ctx) { ctx.provide("phaseOne", { value: "resolved" }) }', - '', - ].join('\n')) - writeFileSync(join(dir, 'reader.mjs'), [ - 'export const name = "reader"', - 'export const inject = ["phaseOne"]', - 'export function apply() {}', - '', - ].join('\n')) - writeFileSync(join(dir, 'cordis.yml'), '[]\n') - const composition: PatchOptions[] = [{ - insert: [ - { id: 'provider', name: './provider.mjs' }, - { - id: 'reader', - name: './reader.mjs', - inject: ['phaseOne'], - config: { value: { __jsExpr: "ctx.get('phaseOne')?.value ?? 'fallback'" } }, - }, - ], - }] - const ctx = await boot(NAME, join(dir, 'cordis.yml'), [ - ...structuredClone(composition), - { id: 'reader', disabled: true }, - ]) + writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n') + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n') + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + ctx.provide('includePath', pathToFileURL(join(dir, 'cordis.yml')).href) try { - // Phase one leaves the reader disabled, so the plugin never ran. - const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader') - expect(reader?.fiber).toBeUndefined() - await applyRootPatches(ctx, structuredClone(composition)) - // Phase two evaluates its config expression against the provided value. - expect(entryConfig(ctx, 'reader')).toEqual({ value: 'resolved' }) + await ctx.loader.create({ + name: 'cordis:include', + config: { path: { __jsExpr: "ctx.get('includePath')" } }, + }) + await ctx.loader.await() + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'noop')).toBe(true) } finally { await ctx.fiber.dispose() } }) - it('does nothing on a tree that was already disposed', async () => { + it('waits for row injections before resolving !!js and resolves again after provider replacement', async () => { const dir = tmp() - const ctx = await boot(NAME, writeTree(dir)) - await ctx.fiber.dispose() - await expect(applyRootPatches(ctx, [])).resolves.toBeUndefined() - }) + writeFileSync(join(dir, 'provider.mjs'), [ + 'export const name = "provider"', + 'export function apply(ctx, config) { ctx.provide("phaseOne", config) }', + '', + ].join('\n')) + writeFileSync(join(dir, 'reader.mjs'), [ + 'export const name = "reader"', + 'export const inject = ["phaseOne"]', + 'export function apply(ctx, config) { ctx.provide("readerResult", config) }', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + const composition: PatchOptions[] = [{ + insert: [ + { + // Consumer-first order proves interpolation follows injection + // readiness rather than YAML position. + id: 'reader', + name: './reader.mjs', + inject: ['phaseOne'], + config: { value: { __jsExpr: 'ctx.phaseOne.fail ? (() => { throw new Error("rejected provider") })() : ctx.phaseOne.value' } }, + }, + { id: 'provider', name: './provider.mjs', config: { value: 'first' } }, + ], + }] + const ctx = await boot(NAME, join(dir, 'cordis.yml'), composition) + try { + expect(ctx.get('readerResult')).toEqual({ value: 'first' }) + const provider = [...ctx.loader.entries()].find(entry => entry.options.id === 'provider') + expect(provider).toBeDefined() + await provider?.update({ disabled: true }) + await ctx.loader.await() + expect(ctx.get('readerResult')).toBeUndefined() + await provider?.update({ config: { value: 'second' } }) + await provider?.update({ disabled: false }) + await ctx.loader.await() + expect(ctx.get('readerResult')).toEqual({ value: 'second' }) - it('fails loud when the tree was booted without the root include', async () => { - const ctx = new Context() - await expect(applyRootPatches(ctx, [])).rejects.toThrow('requires the root Include entry') + await provider?.update({ disabled: true }) + await provider?.update({ config: { fail: true } }) + await provider?.update({ disabled: false }) + await expect(ctx.loader.await()).rejects.toThrow('rejected provider') + expect(ctx.get('readerResult')).toBeUndefined() + + await provider?.update({ disabled: true }) + await provider?.update({ config: { value: 'recovered' } }) + await provider?.update({ disabled: false }) + await ctx.loader.await() + expect(ctx.get('readerResult')).toEqual({ value: 'recovered' }) + } finally { + await ctx.fiber.dispose() + } }) }) diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index dcd4d2416d..9207c4b35d 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/cmdline/README.md -README.md: 242ba184507d88c50e0dcf2ada0a0f7714d87e28 -README.zh.md: 76a76ad6090fcc28d50f9ea2a48d4e2581e361f2 +README.md: cd3350678d38802c18ff26dd47214b5019b8c404 +README.zh.md: ad726cd0726cbbd22736321a8c52b04e23d557fa diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 242ba18450..cd3350678d 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -35,7 +35,7 @@ The Loader-row injection is also its discovery declaration, so no bundle manifes inject: [cmdlineArgs] ``` -The launcher finds active rows with that injection in the composed tree and mounts them before everything else. +The launcher uses that injection only to reject arguments for a composition with no command-line owner. Loader mounts the composition once and holds each row until its own injections are active. Every row the app configures from flags then reads what the startup row resolved, naming the key it takes and the value it falls back to: @@ -44,19 +44,19 @@ Every row the app configures from flags then reads what the startup row resolved name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: - host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1' - port: !!js ctx.get('webStartup')?.port ?? 3080 + host: !!js ctx.webStartup.host ?? '127.0.0.1' + port: !!js ctx.webStartup.port ?? 3080 ``` -`runStartup` parses the arguments, asks `plan` for the values, and provides them as the service. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text and requests exit — nothing is provided, and the rest of the composition never mounts. +`runStartup` parses the arguments, asks `plan` for the values, and provides them as the service. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text and requests exit — nothing is provided, so rows that depend on the startup service never activate. -`plan` receives the options of every row that injects the service, for a value that has to take the composition into account: the `/api` fence authorities are the shipped example, since a bind the composition configured decides whether LAN literals are derived at all. +`plan` receives the startup context and the options of every row that injects the service, for a value that has to take the composition into account. Include still holds nested expressions raw at this point, so a plan that needs a composed fallback can interpolate the relevant row config against the pre-service startup context; the `/api` fence authorities are the shipped example. -### Why the boot has phases +### How injection orders config -A row's config expressions are evaluated when the include applies it, and a strict `ctx.get` only answers for a service whose providing fiber is already active. A composition therefore mounts in two passes: active `cmdlineArgs` consumers alone, then everything else. The rows of the later pass read live values, a `--help` exits before the second pass exists, and a user editing a live patch file re-runs that pass against services that are still up, so a flag cannot be silently reset. +Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch flag cannot be silently reset. -`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). Call it from a row that mounts beside the one being enabled, not from the startup row: a row enabled in the first pass would wait for services the second pass has yet to mount. +`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). Loader applies the enabled row's ordinary injection ordering. ### One command line, one owner diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index 76a76ad609..ad726cd072 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -35,7 +35,7 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字 inject: [cmdlineArgs] ``` -启动器在组合结果中找出带有该注入的活跃行,并先于其他一切挂载它们。 +启动器只用该注入来拒绝那些没有命令行所有者却带有应用参数的组合。Loader 只挂载一次整套组合,并让每一行等待自身的注入激活。 应用用 flag 配置的每一行随后读取启动行解析出的取值,各自点名自己取用的键,以及回退时使用的值: @@ -44,19 +44,19 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字 name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: - host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1' - port: !!js ctx.get('webStartup')?.port ?? 3080 + host: !!js ctx.webStartup.host ?? '127.0.0.1' + port: !!js ctx.webStartup.port ?? 3080 ``` -`runStartup` 解析参数,向 `plan` 索取取值,并把它们作为服务提供出去。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本并请求退出:什么也不会被提供,组合的其余部分也从不挂载。 +`runStartup` 解析参数,向 `plan` 索取取值,并把它们作为服务提供出去。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本并请求退出:什么也不会被提供,因此依赖启动服务的行不会激活。 -`plan` 收到的是所有注入该服务的行的选项,用于那些必须顾及组合本身的取值:随附的例子是 `/api` 栅栏 authority,因为组合所配置的 bind 决定了是否要派生 LAN 字面量。 +`plan` 会收到启动上下文,以及所有注入该服务的行的选项,用于那些必须顾及组合本身的取值。此时 Include 仍保留着嵌套表达式的原始形态,因此需要组合回退值的 plan 可以基于服务提供前的启动上下文插值相关行配置;随附的例子是 `/api` 栅栏 authority。 -### 为什么 boot 分阶段 +### 注入如何排列配置求值 -行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答。因此一套组合分两趟挂载:先是各个活跃的 `cmdlineArgs` 消费方,然后才是其余部分。后一趟的行读到的是活的取值,`--help` 在第二趟存在之前就退出,而用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新运行,因此 flag 不会被悄悄重置。 +Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`:Loader 索取 `webserver` 的配置之前,Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。 -`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。要从与被启用行同一趟挂载的行里调用它,而不是从启动行:在第一趟被启用的行会去等待第二趟才挂载的服务。 +`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。Loader 会对启用后的行应用普通的注入顺序。 ### 一条命令行,一个所有者 diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 2ce1b72942..f64ead7a50 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/headless/README.md -README.md: 45c87f0c85cbb68ad0366ea5f2c86e55fc307309 -README.zh.md: 22322692450fa85a87e9faf903abee0d38968f91 +README.md: 459d0f32788265d43e75922067da3c03d054f444 +README.zh.md: e3ca9d13512e3a13ac71c5cda650fca958609062 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 45c87f0c85..459d0f3278 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, shipped disabled until the startup row supplies the task). It mounts no Host, HTTP server, Web runtime, or browser plugin. +The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected startup service). It mounts no Host, HTTP server, Web runtime, or browser plugin. After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the `headless-startup` row ([`src/startup.ts`](src/startup.ts)) reads it as the positional argument of `dsh --profile headless "task"` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), prints the app's `--help`, and rejects an invocation with no task instead of letting the runner's schema fail. diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 2232269245..e3ca9d1351 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,在启动行供给任务之前以禁用状态交付)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 +dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的启动服务解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:`headless-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))把它读作 `dsh --profile headless "task"` 的位置参数,打印应用自己的 `--help`,并拒绝没有任务的调用,而不是让 runner 的 schema 失败。 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index abe5a95e0d..2c03de11af 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -33,4 +33,4 @@ name: '@deepseek-ai/dsh-headless' inject: [headlessStartup] config: - task: !!js ctx.get('headlessStartup')?.task + task: !!js ctx.headlessStartup.task diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index 74f9dfb6b1..e960c63554 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -64,7 +64,7 @@ function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): H } /** - * Resolve the task and start the runner that reads it. + * Resolve the task for the runner waiting on `headlessStartup`. * @param ctx - plugin context carrying the command line and the Loader. * @returns nothing once the runner is started, or once `--help` or a missing task requested exit. */ diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 651ef0aebb..51c6708c8f 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -1,8 +1,7 @@ /** - * The one-shot app's startup row over a REAL Loader tree: the task - * positional becomes the value the runner row reads, a missing task is a usage - * error, and the web service this app absorbs is provided too, so the web rows - * it rides over resolve on their own fallbacks. + * The one-shot app's startup row over a real Loader tree: the task positional + * becomes the injected runner config, while help and usage errors leave the + * runner pending. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -13,7 +12,6 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' -import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup' import { afterEach, describe, expect, it } from 'vitest' import { apply, HEADLESS_STARTUP_SERVICE, type HeadlessStartupValues } from '../src/startup.ts' @@ -21,6 +19,7 @@ import { apply, HEADLESS_STARTUP_SERVICE, type HeadlessStartupValues } from '../ interface Observed { exits: number[] out: string + runnerConfig?: unknown } const disposers: (() => Promise<void>)[] = [] @@ -32,22 +31,20 @@ afterEach(async () => { }) /** - * Mount the real startup row over stand-ins for the runner row and one web - * row this app absorbs, the way a profile mounts phase one. + * Mount the real startup row over a runner stand-in. * @param args - the invocation's inner arguments. - * @param options - fixture knobs for the shapes a composition can take. - * @returns the resolved service values (absent when the app requested exit) and what the boot observed. + * @param options - fixture knobs for invalid compositions. + * @returns the resolved startup value and observed runner/process effects. */ async function bootStartup( args: string[], options: { withoutRunner?: boolean } = {}, -): Promise<{ task: HeadlessStartupValues | undefined; web: unknown; observed: Observed }> { +): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-')) const observed: Observed = { exits: [], out: '' } - writeFileSync(join(dir, 'row.mjs'), 'export function apply() {}\n') - // The Loader imports a row through Node's own resolver, which cannot resolve - // this workspace's sources; the row delegates to the real plugin the test - // imported through the source-plane path mapping. + writeFileSync(join(dir, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n') + // Loader imports through Node's resolver, so this fixture delegates to the + // source-plane plugin already imported by the test. writeFileSync(join(dir, 'startup.mjs'), ` export const name = 'headless-startup' export const inject = ['cmdlineArgs'] @@ -55,16 +52,11 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) `) const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href writeFileSync(join(dir, 'cordis.yml'), [ - // A composition that lost the runner still injects the service, so the - // startup row reaches its own row check rather than the generic one. options.withoutRunner === true ? '- id: displaced-runner' : '- id: headless-runner', ` name: ${rowUrl}`, ` inject: [${HEADLESS_STARTUP_SERVICE}]`, - ' disabled: true', - '- id: webserver', - ` name: ${rowUrl}`, - ` inject: [${WEB_STARTUP_SERVICE}]`, - ' disabled: true', + ' config:', + ' task: !!js ctx.headlessStartup.task', '- id: headless-startup', ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, ' inject: [cmdlineArgs]', @@ -73,7 +65,12 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } internals.stdout = observing internals.stderr = observing - ;(globalThis as unknown as { __headlessStartupApply: typeof apply }).__headlessStartupApply = apply + const globals = globalThis as unknown as { + __headlessStartupApply: typeof apply + __headlessStartupObserved: Observed + } + globals.__headlessStartupApply = apply + globals.__headlessStartupObserved = observed const ctx = new Context() await ctx.plugin(Loader) @@ -84,38 +81,35 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) disposers.push(async () => { await ctx.fiber.dispose() }) return { task: ctx.get(HEADLESS_STARTUP_SERVICE) as HeadlessStartupValues | undefined, - web: ctx.get(WEB_STARTUP_SERVICE), observed, } } describe('headless startup', () => { - it('joins the task positional into the value the runner reads', async () => { + it('joins the task positional into the runner config', async () => { const { task, observed } = await bootStartup(['run', 'the', 'tests']) expect(task).toEqual({ task: 'run the tests' }) + expect(observed.runnerConfig).toEqual({ task: 'run the tests' }) expect(observed.exits).toEqual([]) }) - it('provides the web service it absorbed, so those rows resolve on their own fallbacks', async () => { - const { web } = await bootStartup(['task']) - expect(web).toEqual({ task: 'task' }) - }) - - it('rejects an invocation with no task instead of failing inside the runner schema', async () => { + it('rejects an invocation with no task and leaves the runner pending', async () => { const { task, observed } = await bootStartup([]) expect(observed.out).toContain('a task is required') expect(task).toBeUndefined() + expect(observed.runnerConfig).toBeUndefined() expect(observed.exits).toEqual([1]) }) - it('prints its own help and resolves nothing', async () => { + it('prints its own help and leaves the runner pending', async () => { const { task, observed } = await bootStartup(['--help']) expect(observed.out).toContain('dsh --profile headless') expect(task).toBeUndefined() + expect(observed.runnerConfig).toBeUndefined() expect(observed.exits).toEqual([0]) }) - it('fails the boot when the composition has no runner row to give the task to', async () => { + it('fails when the composition has no runner row', async () => { await expect(bootStartup(['task'], { withoutRunner: true })) .rejects.toThrow('the composition has no waiting "headless-runner" row') }) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 7a86cb1df0..656a3374cb 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -7,11 +7,10 @@ # # Rows this app configures from flags read them from the `webStartup` service: # each names the key it takes and the value it falls back to, so a flag wins -# over the value written beside it. The web-startup row injects `cmdlineArgs`, -# so the launcher runs it first; it has parsed --host/--port/--dev/ -# --workspace-root/--trusted-host by the time those configs resolve. -# `dsh --profile web --help` therefore prints this app's own help and exits -# before the rest of the composition mounts at all. +# over the value written beside it. The web-startup row injects `cmdlineArgs` +# and provides `webStartup`; Loader delays dependent-row config interpolation +# until that service is active. `dsh --profile web --help` provides no service, +# so the server rows never activate. # ── surface-specific values the base deliberately omits ───────────────────── @@ -85,9 +84,8 @@ config: workspaceRoot: !!js ctx.get('webStartup')?.workspaceRoot - # This app's command-line startup row: its `cmdlineArgs` injection makes the - # launcher mount it first. It owns the web flag family and its --help, and - # provides webStartup with the values this invocation resolved. + # This app's command-line startup row. It owns the web flag family and its + # --help, and provides webStartup to the rows that inject it. - id: web-startup name: '@deepseek-ai/dsh-web-app/startup' inject: [cmdlineArgs] diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index 96692e0116..e636366f3c 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -11,7 +11,7 @@ import { networkInterfaces } from 'node:os' import { Command } from 'commander' import type { Context } from 'cordis' -import type { EntryOptions } from '@cordisjs/plugin-loader' +import { interpolate, type EntryOptions } from '@cordisjs/plugin-loader' import { runStartup } from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ @@ -50,6 +50,20 @@ export interface WebStartupValues { /** The webserver schema's all-interfaces bind literal: only this bind derives LAN authorities. */ const ALL_INTERFACES_HOST = '0.0.0.0' +/** + * Read the deployment trust list before its row mounts and validates config. + * @param config - the connection row's config resolved before `webStartup` exists. + * @returns its configured authorities, or an empty list when absent. + * @throws when the file-backed config is not an array of strings. + */ +function configuredTrustedHosts(config: unknown): string[] { + const value = (config as { trustedHosts?: unknown } | undefined)?.trustedHosts + if (value === undefined) return [] + const valid = Array.isArray(value) && value.every((entry: unknown) => typeof entry === 'string') + if (!valid) throw new Error('web-startup: the composed connection trustedHosts must be an array of strings') + return value +} + /** * Non-internal IPv4 interface addresses of this machine — the IP-literal * authorities an all-interfaces bind is reachable by on the LAN. @@ -118,19 +132,33 @@ Examples: * Turn the parsed flags into the values the web rows read. * @param program - the parsed web command. * @param rows - the waiting rows' composed options, in tree order. + * @param ctx - the startup context used to resolve composed fallbacks before `webStartup` exists. * @returns the web rows' service value. */ -function planWebStartup(program: Command, rows: readonly EntryOptions[]): WebStartupValues { +function planWebStartup(program: Command, rows: readonly EntryOptions[], ctx: Context): WebStartupValues { const options = program.opts<WebOptions>() if (options.port !== undefined && !/^\d+$/.test(options.port)) { program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) } - const webserver = rows.find(row => row.id === 'webserver') - if (webserver === undefined) throw new Error('web-startup: the web composition has no waiting "webserver" row to configure') - // The bind this invocation ends on: the flag, else what the row falls back - // to, which is the same literal its config expression names. - const bindHost = options.host ?? (webserver.config as { host?: string } | undefined)?.host - const { lanAddresses, trustedHosts } = resolveLanTrust(bindHost, options.trustedHost ?? []) + const row = (id: string): EntryOptions => { + const found = rows.find(candidate => candidate.id === id) + if (found === undefined) throw new Error(`web-startup: the web composition has no waiting ${JSON.stringify(id)} row to configure`) + return found + } + const webserver = row('webserver') + row('api-gateway') + row('web-runtime') + const connection = row('connection') + // Include preserves nested row expressions until their own injections are + // active. Resolve just the composed fields this startup plan needs against + // the pre-service context, where their `ctx.get('webStartup')` fallback wins. + const webserverConfig = interpolate(ctx, webserver.config) as { host?: string } | undefined + const connectionConfig: unknown = interpolate(ctx, connection.config) + const bindHost = options.host ?? webserverConfig?.host + const sampled = resolveLanTrust(bindHost, options.trustedHost ?? []) + // Preserve deployment authorities when invocation-derived LAN literals or + // explicit extras become the runtime value read by the connection row. + const composedTrusted = configuredTrustedHosts(connectionConfig) return { ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, @@ -138,15 +166,15 @@ function planWebStartup(program: Command, rows: readonly EntryOptions[]): WebSta // mode and lanAddresses describe this invocation, never the deployment, so // they are resolved on every boot. mode: options.dev === true ? 'development' : 'production', - trustedHosts, - lanAddresses, + trustedHosts: [...composedTrusted, ...sampled.trustedHosts], + lanAddresses: sampled.lanAddresses, } } /** - * Resolve the web flag family and start the rows that read it. + * Resolve the web flag family for rows waiting on `webStartup`. * @param ctx - plugin context carrying the command line and the Loader. - * @returns nothing once the web rows are started, or once `--help` requested exit. + * @returns nothing once the values are provided, or once `--help` requested exit. */ export function apply(ctx: Context): void { runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup) diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index 83def845cd..5a7c80dcc4 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -40,14 +40,16 @@ afterEach(async () => { /** * Mount the real startup row over a stand-in for the `webserver` row whose - * composed bind it reads, the way a profile mounts phase one. + * composed bind it reads before the dependent rows activate. * @param args - the invocation's inner arguments. * @param webserverConfig - the composed `webserver` row config, or `null` to omit the row. + * @param trustedHosts - authorities the composed connection row already carries, or `null` when it carries none. * @returns the resolved service value (absent when the app requested exit) and what the boot observed. */ async function bootStartup( args: string[], webserverConfig: Record<string, unknown> | null = { host: '127.0.0.1', port: 3080 }, + trustedHosts: unknown = [], ): Promise<{ values: WebStartupValues | undefined; observed: Observed; ctx: Context }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-')) const observed: Observed = { exits: [], out: '' } @@ -68,8 +70,20 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) ` inject: [${WEB_STARTUP_SERVICE}]`, ' disabled: true', ' config:', - ...Object.entries(webserverConfig).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`), + ...Object.entries(webserverConfig).map(([key, value]) => ` ${key}: !!js ctx.get('${WEB_STARTUP_SERVICE}')?.${key} ?? ${JSON.stringify(value)}`), ], + '- id: connection', + ` name: ${rowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ' disabled: true', + ...trustedHosts === null ? [] : [ + ' config:', + ` trustedHosts: !!js ctx.get('${WEB_STARTUP_SERVICE}')?.trustedHosts ?? ${JSON.stringify(trustedHosts)}`, + ], + '- id: api-gateway', + ` name: ${rowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ' disabled: true', // A second reader keeps the composition honest when the webserver row is // the one under test: the service must still have someone to serve. '- id: web-runtime', @@ -121,13 +135,36 @@ describe('web startup', () => { expect(values).not.toHaveProperty('port') }) - it('derives the LAN literals for an all-interfaces bind, and the extras with them', async () => { - const { values } = await bootStartup(['--host', '0.0.0.0', '--trusted-host', 'lab.internal']) - expect(values?.trustedHosts).toEqual(['192.168.1.5', 'lab.internal']) + it('adds LAN literals and explicit extras after the composed fence authorities', async () => { + const { values } = await bootStartup( + ['--host', '0.0.0.0', '--trusted-host', 'lab.internal', 'lab-2.internal', '--trusted-host', '10.0.0.9'], + { host: '127.0.0.1', port: 3080 }, + ['profile.internal'], + ) + expect(values?.trustedHosts).toEqual([ + 'profile.internal', '192.168.1.5', 'lab.internal', 'lab-2.internal', '10.0.0.9', + ]) // Display gets the same single sample the fence was configured with. expect(values?.lanAddresses).toEqual(['192.168.1.5']) }) + it('starts from an empty trust list when the composed connection row names none', async () => { + const { values } = await bootStartup( + ['--trusted-host', 'lab.internal'], + { host: '127.0.0.1', port: 3080 }, + null, + ) + expect(values?.trustedHosts).toEqual(['lab.internal']) + }) + + it.each([ + 'profile.internal', + ['profile.internal', 1], + ])('rejects an invalid composed trust list before transforming it (%j)', async (trustedHosts) => { + await expect(bootStartup([], { host: '127.0.0.1', port: 3080 }, trustedHosts)) + .rejects.toThrow('the composed connection trustedHosts must be an array of strings') + }) + it('reads the composed bind when no flag names one, so a configured 0.0.0.0 still derives them', async () => { const { values } = await bootStartup([], { host: '0.0.0.0', port: 3080 }) expect(values?.lanAddresses).toEqual(['192.168.1.5']) @@ -135,8 +172,8 @@ describe('web startup', () => { it('reports the development mode for --dev, which the web runtime reads', async () => { const { values } = await bootStartup(['--dev']) - // The runtime row is what turns the reload chain on, in the phase whose - // host rows it needs; this row only reports the mode. + // The runtime row turns the reload chain on after its host dependencies + // activate; this row only reports the mode. expect(values?.mode).toBe('development') }) diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 5613ed035e..ab7370c033 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -39,18 +39,6 @@ function requiredConfig() { }) } -function queuedReadinessConfig( - ctx: Context, - onPublished: (dispose: () => void) => void, -) { - return z.transform(z.any(), () => { - queueMicrotask(() => { - onPublished(ctx.provide(TEST_INVARIANT_READY_SERVICE, true)) - }) - return {} - }, true) -} - function invalidConfigApply(): never { throw new Error('invalid plugin apply executed') } @@ -189,84 +177,55 @@ describe('global test invariant host', () => { expect(apply).not.toHaveBeenCalled() }) - it('disposes invalid config when readiness refresh wins the rejection-handler race', async () => { + it('disposes invalid config after delayed invariant readiness', async () => { await withDelayedFirstCompanion( async ({ started, release }) => { const ctx = new Context() const apply = vi.fn(invalidConfigApply) - let disposeQueuedReadiness: (() => void) | undefined const plugin = { apply, - Config: z.intersect([ - queuedReadinessConfig(ctx, (dispose) => { - disposeQueuedReadiness = dispose - }), - requiredConfig(), - ]), + Config: requiredConfig(), } const fiber = ctx.plugin(plugin, {}) - const firstError = await rejectionOf(fiber) - expectRequiredConfigValidation(firstError) - expect(fiber.state).toBe(FiberState.DISPOSED) + const returnedError = rejectionOf(fiber) + await started + expect(fiber.state).toBe(FiberState.PENDING) expect(apply).not.toHaveBeenCalled() - await started - if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') - disposeQueuedReadiness() release() - await ctx.plugin(TestInvariantProbe) - - const secondError = await rejectionOf(fiber) - expect(secondError).toBe(firstError) + expectRequiredConfigValidation(await returnedError) expect(fiber.state).toBe(FiberState.DISPOSED) expect(apply).not.toHaveBeenCalled() }, ) }) - it('retains a valid plugin failure when readiness wins the initial-probe race', async () => { + it('retains a valid plugin failure after delayed invariant readiness', async () => { await withDelayedFirstCompanion( async ({ started, release }) => { const ctx = new Context() const failure = new Error('valid plugin apply failed') - const applied = deferred() const apply = vi.fn(function validConfigApply() { - applied.resolve() throw failure }) - let disposeQueuedReadiness: (() => void) | undefined const plugin = { apply, - Config: queuedReadinessConfig(ctx, (dispose) => { - disposeQueuedReadiness = dispose - }), + Config: z.object({}), } const fiber = ctx.plugin(plugin, {}) const returnedError = rejectionOf(fiber) - try { - await Promise.all([started, applied.promise]) - expect(fiber.state).toBe(FiberState.FAILED) - expect(apply).toHaveBeenCalledOnce() - expect(ctx.registry.has(plugin)).toBe(true) - expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) + await started + expect(fiber.state).toBe(FiberState.PENDING) + expect(apply).not.toHaveBeenCalled() - if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') - Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) - disposeQueuedReadiness() - release() - - expect(await returnedError).toBe(failure) - expect(fiber.state).toBe(FiberState.FAILED) - expect(apply).toHaveBeenCalledOnce() - expect(ctx.registry.has(plugin)).toBe(true) - expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) - } finally { - Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) - disposeQueuedReadiness?.() - release() - } + release() + expect(await returnedError).toBe(failure) + expect(fiber.state).toBe(FiberState.FAILED) + expect(apply).toHaveBeenCalledOnce() + expect(ctx.registry.has(plugin)).toBe(true) + expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) }, ) }) diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index fa3c3cc7e4..5b447f5f4a 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -6,7 +6,7 @@ */ import { expect } from 'vitest' -import { FiberState, Inject, RegistryService } from '@deepseek-ai/cordis' +import { FiberState, Inject, RegistryService, ValidationError } from '@deepseek-ai/cordis' import type { Context, Plugin } from '@deepseek-ai/cordis' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { @@ -248,22 +248,25 @@ function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugi function joinInvariantStartup( fiber: PluginFiber, invariantReady: Promise<void>, - disposeInitialFailure = false, + disposePendingValidationFailure = false, ): PluginFiber { // RegistryService returns a thenable wrapper whose context still points to // the raw Fiber. Calling inherited await() on the wrapper would return and // assimilate that thenable, accidentally following later plugin startup. const rawFiber = fiber.ctx.fiber - const initialized = disposeInitialFailure - ? rawFiber.await().catch(async (error: unknown) => { - // Config validation is the only failure recorded while a gated fiber - // is initially PENDING. Dispose it even if queued readiness publication - // changes its state before this rejection handler runs. - await rawFiber.dispose() + const readiness = invariantReady.then(async () => { + try { + return await rawFiber.await() + } catch (error) { + // Config resolves only after the readiness injection activates. Dispose + // validation failures owned by an initially pending target; ordinary + // callback failures remain inspectable. + if (disposePendingValidationFailure && error instanceof ValidationError) { + await rawFiber.dispose() + } throw error - }) - : Promise.resolve() - const readiness = initialized.then(() => invariantReady).then(() => rawFiber.await()) + } + }) const joined = Object.create(fiber) as PluginFiber joined.then = readiness.then.bind(readiness) return joined diff --git a/vendor/README.md b/vendor/README.md index 470b517549..0666143b54 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -37,7 +37,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. +8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, contain sibling-start failures after their owning tree is disposed, undo changes and additions on live-update failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. 10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. @@ -45,6 +45,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. 14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. 15. **`@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). +16. **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`. ## Sync procedure diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index e18940a830..e9bd85e3e1 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -331,6 +331,12 @@ export interface Events { 'internal/plugin'(fiber: Fiber): void /** A fiber changed lifecycle state; receives the fiber and its previous state. */ 'internal/status'(fiber: Fiber, oldValue: FiberState): void + /** + * Resolve raw plugin config after the fiber's injections become active. + * @param config - the raw config for this activation. + * @mode waterfall + */ + 'internal/config'(this: Fiber, config: any, next: () => any): any /** Interception hook for a service binding (no core producer). */ 'internal/service'(this: Context, name: string, value: any): void /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */ diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index a8c804207d..38a3197e29 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -188,6 +188,8 @@ export class Fiber { public readonly ctx: Context /** The validated plugin config (updated by `update()`). */ public config: any + /** The raw plugin config, re-resolved before each activation. */ + public _config: any /** Current lifecycle state; transitions emit `internal/status`. */ public state = FiberState.PENDING /** Dispose this fiber: unload the plugin, then settle once cleanup finished. */ @@ -224,6 +226,7 @@ export class Fiber { public runtime: Plugin.Runtime | null, getOuterStack: () => string[], ) { + this._config = config const collect = (dispose: Disposable) => { this._disposables.push(dispose) } @@ -259,16 +262,8 @@ export class Fiber { collect, } - let shouldRefresh = false this.dispose = parent.fiber.effect(() => { const remove = runtime.fibers.push(this) - try { - this.config = resolveConfig(runtime, config) - shouldRefresh = true - } catch (error) { - this.ctx.logger.error(error) - this._error = error - } return async () => { this.uid = null emitPluginDisposed(this.context, this) @@ -320,7 +315,7 @@ export class Fiber { for (const name of Object.keys(this.inject)) { this._checkImpl(name) } - if (shouldRefresh) this._refresh() + this._refresh() } } else { this.uid = 0 @@ -643,6 +638,11 @@ export class Fiber { }) } + private _resolveConfig(config: any) { + config = this.context.waterfall(this, 'internal/config', config, () => config) + return this.runtime ? resolveConfig(this.runtime, config) : config + } + private async _reload() { this.store = { ...this._store } const oldEpoch = this._runner.epoch @@ -652,7 +652,9 @@ export class Fiber { // the load. Do not run plugin code for a stale epoch; the state update // below will drain any effects collected while the fiber was PENDING. if (this._runner.epoch === oldEpoch) { + this.config = this._resolveConfig(this._config) await this._execute(this._runner) + this._error = undefined } } catch (reason) { // impl guarantees that the error is non-null (?) @@ -733,7 +735,16 @@ export class Fiber { */ update(config: any, noSave = false) { this.assertActive() - config = resolveConfig(this.runtime!, config) + this._config = config + if (this.state !== FiberState.ACTIVE) { + // Config resolution may access injected services, so defer it until the + // fiber can activate. + this._error = undefined + this._setEpoch(INACTIVE) + this._refresh() + return + } + config = this._resolveConfig(config) return this.context.waterfall(this, 'internal/update', config, noSave, () => { this.config = config this._error = undefined diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 290899ba1f..f79d8344dc 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -502,7 +502,7 @@ class Hmr extends Service { const reload = (plugin: any, runtime: Plugin.Runtime) => { if (!runtime) return for (const oldFiber of runtime.fibers) { - const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber.config, this.getOuterStack) + const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber._config, this.getOuterStack) fiber.entry = oldFiber.entry if (fiber.entry) fiber.entry.fiber = fiber } diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 04079cc598..c67b591978 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -1,4 +1,4 @@ -import { EntryTree, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { EntryConfigResolver, EntryTree, interpolate, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { Context, Service } from '@deepseek-ai/cordis' import { extname } from 'node:path' import { access, constants, readFile, rename, writeFile } from 'node:fs/promises' @@ -174,6 +174,21 @@ export namespace Include { export class Include extends EntryTree { static inject = ['loader'] + /** + * Resolve Include's own options while preserving nested entry expressions. + * @param ctx - the Include plugin context. + * @param config - the raw Include config. + * @returns resolved Include options with `initial` and `patches` untouched. + */ + static [EntryConfigResolver](ctx: Context, config: Include.Config): Include.Config { + const { initial, patches, ...own } = config + return { + ...interpolate(ctx, own), + ...(initial === undefined ? {} : { initial }), + ...(patches === undefined ? {} : { patches }), + } + } + public filename: string private type?: string private readonly: boolean diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 215198468f..0f35cdfa97 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -3,7 +3,13 @@ import { deepEqual, isNullable } from '@deepseek-ai/cosmokit' import { Loader } from '../index.ts' import { EntryGroup } from './group.ts' import { EntryTree } from './tree.ts' -import { evaluate, interpolate } from './utils.ts' +import { evaluate } from './utils.ts' + +/** Static plugin hook for resolving a container config while preserving nested entry configs. */ +export const EntryConfigResolver = Symbol.for('cordis.loader.entry-config-resolver') + +/** Resolver installed at {@link EntryConfigResolver}. */ +export type EntryConfigResolver = (ctx: Context, config: any) => any /** Serialized plugin entry options stored in loader config files. */ export interface EntryOptions { @@ -101,17 +107,12 @@ export class Entry { return evaluate(this.ctx, expr) } - _resolveConfig(plugin: any): [any, any?] { - if (plugin[EntryGroup.key]) return this.options.config - return interpolate(this.ctx, this.options.config) - } - private async _patchContext(diff: string[]) { await this.context.waterfall('loader/patch-context', this, async () => { Object.setPrototypeOf(this.ctx, this.parent.ctx) if (this.fiber?.uid && (diff.includes('config') || this.options.group)) { - await this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true) + await this.fiber.update(this.options.config, true) } }) } @@ -258,7 +259,15 @@ export class Entry { this._initTask = undefined if (!this.loader.getTasks().length) this.ctx.reflect.notify(['loader']) } - await this.fiber?.await() + await this._await() + } + + async _await() { + try { + await this.fiber?.await() + } catch (error) { + throw updateError('apply', this.options, error) + } } private async _init() { @@ -278,17 +287,13 @@ export class Entry { private async _start(plugin: any) { let fiber: Fiber | undefined try { - fiber = await this._create(plugin) + await this._patchContext([]) + this.loader.showLog(this, 'apply') + fiber = this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack) await fiber.await() } catch (error) { await this._dispose(fiber) throw error } } - - private async _create(plugin: any): Promise<Fiber> { - await this._patchContext([]) - this.loader.showLog(this, 'apply') - return this.fiber = this.ctx.registry.plugin(plugin, this._resolveConfig(plugin), this.getOuterStack) - } } diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index a7b0997297..2e322b1e26 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -69,6 +69,10 @@ export class EntryGroup { try { const outcomes = await Promise.allSettled(config.map(options => this.create(options))) + // Disposal owns termination: sibling starts can still be settling after + // the containing tree has gone away, but their failures no longer + // describe a live update to roll back. + if (this.ctx.fiber.uid === null) return const failures = outcomes .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') .map(outcome => outcome.reason) diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 4b5ac78ef7..c1925f0b90 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -51,7 +51,7 @@ export abstract class EntryTree { continue } const outcomes = await Promise.allSettled( - [...this.entries()].map(entry => entry.fiber?.await()), + [...this.entries()].map(entry => entry._await()), ) const failures = outcomes .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index fa1f852cff..3fe3e57949 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -1,9 +1,16 @@ -import { Context, Inject, Service } from '@deepseek-ai/cordis' +import { Context, FiberState, Inject, Service, type Fiber } from '@deepseek-ai/cordis' import { defineProperty, isNullable, type Dict } from '@deepseek-ai/cosmokit' import { ModuleLoader } from './internal.ts' -import { Entry, type EntryOptions } from './config/entry.ts' +import { + Entry, + EntryConfigResolver, + type EntryConfigResolver as ConfigResolver, + type EntryOptions, +} from './config/entry.ts' +import { EntryGroup } from './config/group.ts' import isolate from './config/isolate.ts' import { EntryTree } from './config/tree.ts' +import { interpolate } from './config/utils.ts' /** Re-export entry node APIs. */ export * from './config/entry.ts' @@ -87,6 +94,15 @@ export class Loader extends EntryTree { ctx.reflect.provide('loader', this, this[Service.check]) + ctx.on('internal/config', function (this: Fiber, _config, next) { + const config = next() + if (!this.entry || this.parent.fiber?.entry === this.entry) return config + const plugin = this.runtime?.callback as Record<PropertyKey, unknown> | undefined + if (plugin?.[EntryGroup.key]) return config + const resolve = plugin?.[EntryConfigResolver] as ConfigResolver | undefined + return resolve ? resolve(this.ctx, config) : interpolate(this.ctx, config) + }, { global: true }) + ctx.on('internal/update', async function (config, noSave, next) { if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next() await next() @@ -127,7 +143,8 @@ export class Loader extends EntryTree { if (!ctx.registry.has(fiber.runtime!.callback)) return // case 5: the entry's tree is being disposed - if (!fiber.entry.parent.tree.ctx.fiber.uid) return + const treeOwner = fiber.entry.parent.tree.ctx.fiber + if (!treeOwner.uid || treeOwner.state === FiberState.UNLOADING) return // case 6: Loader is replacing or removing this exact fiber if (fiber.entry._disposing) return From d4ccfbd80ff7e19279c9851bd8c1ed16816aac30 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Sun, 9 Aug 2026 18:23:25 +0800 Subject: [PATCH 161/229] refactor(cli)!: complete app-owned profile startup --- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 4 +-- ...026-07-19-gui-layering-and-rpc-protocol.md | 8 ++--- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 8 ++--- ...-07-29-dsh-source-launch-tsx-esm.i18n.yaml | 4 +-- .../2026-07-29-dsh-source-launch-tsx-esm.md | 2 +- ...2026-07-29-dsh-source-launch-tsx-esm.zh.md | 2 +- ...026-08-05-profile-plugin-bundles.i18n.yaml | 4 +-- .../2026-08-05-profile-plugin-bundles.md | 2 +- .../2026-08-05-profile-plugin-bundles.zh.md | 2 +- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +-- .../2026-08-06-app-owned-command-line.md | 4 +-- .../2026-08-06-app-owned-command-line.zh.md | 4 +-- ...headless-direct-core-entry-point.i18n.yaml | 4 +-- ...-08-09-headless-direct-core-entry-point.md | 6 ++-- ...-09-headless-direct-core-entry-point.zh.md | 6 ++-- ...3-cli-signal-shutdown-escalation.i18n.yaml | 4 +-- ...26-08-03-cli-signal-shutdown-escalation.md | 2 +- ...08-03-cli-signal-shutdown-escalation.zh.md | 2 +- ...6-08-08-dsh-run-headless-command.i18n.yaml | 4 +-- .../2026-08-08-dsh-run-headless-command.md | 2 ++ .../2026-08-08-dsh-run-headless-command.zh.md | 2 ++ ...-20-remove-stdio-and-echo-agents.i18n.yaml | 4 +-- ...2026-07-20-remove-stdio-and-echo-agents.md | 4 +-- ...6-07-20-remove-stdio-and-echo-agents.zh.md | 4 +-- .../2026-08-08-remove-cli-demo.i18n.yaml | 4 +-- .../2026-08-08-remove-cli-demo.md | 14 ++++---- .../2026-08-08-remove-cli-demo.zh.md | 14 ++++---- README.i18n.yaml | 4 +-- README.md | 2 +- README.zh.md | 2 +- apps/cli/README.i18n.yaml | 4 +-- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/package.json | 1 + apps/cli/reference/README.i18n.yaml | 4 +-- apps/cli/reference/README.md | 6 ++-- apps/cli/reference/README.zh.md | 6 ++-- apps/cli/src/bin.ts | 4 +-- apps/cli/src/profile-boot.ts | 7 +++- apps/cli/tests/headless-shutdown.e2e.ts | 2 +- apps/cli/tsconfig.json | 5 ++- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 13 +++---- docs/config-catalog.zh.md | 13 +++---- docs/testing.i18n.yaml | 4 +-- docs/testing.md | 2 +- docs/testing.zh.md | 2 +- docs/user/develop/basic/publish.i18n.yaml | 4 +-- docs/user/develop/basic/publish.md | 14 ++++++-- docs/user/develop/basic/publish.zh.md | 14 ++++++-- docs/user/guide/quickstart.i18n.yaml | 4 +-- docs/user/guide/quickstart.md | 6 ++-- docs/user/guide/quickstart.zh.md | 6 ++-- examples/headless-agent/README.i18n.yaml | 4 +-- examples/headless-agent/README.md | 4 +-- examples/headless-agent/README.zh.md | 4 +-- ...cordis.yml => headless-profile.cordis.yml} | 0 .../headless-agent/tests/headless.snapshot.ts | 36 +++++++++---------- .../session.expected.jsonl | 8 ++--- .../stderr.expected.txt | 0 package.json | 2 +- packages/boot/README.i18n.yaml | 4 +-- packages/boot/app-boot/README.i18n.yaml | 4 +-- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/index.ts | 2 +- packages/boot/cmdline/README.i18n.yaml | 6 ++-- packages/boot/cmdline/README.md | 2 +- packages/boot/cmdline/README.zh.md | 2 +- packages/boot/cmdline/package.json | 2 +- packages/boot/cmdline/src/index.ts | 11 +++--- packages/bundle/headless/src/index.ts | 2 +- packages/bundle/headless/tsconfig.json | 5 +-- packages/bundle/web-app/README.i18n.yaml | 4 +-- packages/bundle/web-app/README.md | 8 ++--- packages/bundle/web-app/README.zh.md | 8 ++--- packages/bundle/web-app/cordis.patch.yml | 11 +++--- packages/bundle/web-app/package.json | 2 +- packages/bundle/web-app/src/index.ts | 4 +-- packages/bundle/web-app/src/startup.ts | 24 +++++-------- packages/bundle/web-app/tests/startup.spec.ts | 7 +--- packages/bundle/web-app/tsconfig.json | 5 ++- .../core/agent-default-model/README.i18n.yaml | 4 +-- packages/core/agent-default-model/README.md | 2 +- .../core/agent-default-model/README.zh.md | 2 +- packages/examples/README.i18n.yaml | 4 +-- packages/examples/README.md | 2 +- packages/examples/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +-- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- pnpm-lock.yaml | 6 ++++ scripts/gen-cordis-catalog.ts | 3 ++ .../request-response.expected.json | 4 +-- tsconfig.base.json | 2 ++ tsdown.config.ts | 2 +- vendor/loader/src/config/entry.ts | 7 +++- 97 files changed, 260 insertions(+), 224 deletions(-) rename examples/headless-agent/tests/fixtures/{dsh-run.cordis.yml => headless-profile.cordis.yml} (100%) rename examples/headless-agent/tests/snapshots/{dsh-run => headless-profile}/session.expected.jsonl (91%) rename examples/headless-agent/tests/snapshots/{dsh-run => headless-profile}/stderr.expected.txt (100%) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index bc2d26325d..3d370c1e37 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 514deb890d4e08d465db869669078473d32fb215 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: f6fa71e3dac25f48b2ad4744a0cc695417528b34 +2026-07-19-gui-layering-and-rpc-protocol.md: da96ae97f2a2d64aeef7794bd82ccbd86602b1ad +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 36dc7391bc3f9bb0d5105fea14a2763d0b7159a1 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 514deb890d..da96ae97f2 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -10,7 +10,7 @@ English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product clients are coming — Web (server), Electron, and others. We call them Clients and want the following capabilities: -- One `dsh` process supporting both `dsh web` (serve) and `dsh run` (headless) — one process, two modes (a design reservation) +- One `dsh` process supporting both `dsh web` (serve) and `dsh --profile headless` (headless) — one process, two modes (a design reservation) - Launching inside Electron with the same Web technologies as `dsh web` That demands a stable layered responsibility model in the engineering codebase, so future clients plug in cleanly. @@ -31,7 +31,7 @@ Directories layer as follows: - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dsh.client` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported applications, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - - `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh --profile headless` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. - A future Electron application reuses the same web client packages over an IPC fetch carrier. ``` @@ -79,7 +79,7 @@ Packages under `packages/host/*` and `packages/client/*` **must carry the direct 2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the application's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app. 3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports. -The two existing applications preserve the division: the Web application mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem and mount directly via `ctx.plugin(entry-point plugin)` without fetch. +The two existing applications preserve the division: the Web application mounts Host, carrier, and browser composition, while `dsh --profile headless` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem and mount directly via `ctx.plugin(entry-point plugin)` without fetch. ## Message protocol @@ -215,7 +215,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation | Subclass | Package | doFetch | Purpose | |---|---|---|---| -| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh run` drives core directly | +| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh --profile headless` drives core directly | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser client; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | | IPC bridge subclass (hypothetical example — no such shell exists) | an Electron shell | IPC serialization round trip | would swap only doFetch; contract and base class unchanged | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index f6fa71e3da..36dc7391bc 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -9,7 +9,7 @@ Status: implemented ## Problem 需要提供 UI 对接层,除已有 ACP(Agent Client Protocol)/stdio 基线外,还需要 Web(server)、Electron 等其他产品客户端。我们把它们统一称为 Client。希望具备以下能力: -- 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh run`(headless),一个进程两种模式(设计预留) +- 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh --profile headless`(headless),一个进程两种模式(设计预留) - 在 Electron 中使用与 `dsh web` 相同的 Web 技术启动 那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client。 @@ -29,7 +29,7 @@ Status: implemented - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 + - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 - 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。 ``` @@ -77,7 +77,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. 2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该应用私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。 3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。 -现有两个应用保持这一区分:Web 应用挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不使用 fetch。 +现有两个应用保持这一区分:Web 应用挂载 Host、载体与浏览器组合,而 `dsh --profile headless` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不使用 fetch。 ## 消息协议 @@ -213,7 +213,7 @@ export type ResponseValue<K> = | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| -| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh run` 直接驱动 core | +| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh --profile headless` 直接驱动 core | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | IPC 桥子类(假想示例——尚无此形态) | Electron 壳 | IPC 序列化往返 | 只需换 doFetch,约定/基类零改 | diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml index 8f17ecd480..f4fb753e79 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md -2026-07-29-dsh-source-launch-tsx-esm.md: ed22e51d59a25db130b3760ce484c116bade4348 -2026-07-29-dsh-source-launch-tsx-esm.zh.md: bdd549092eb30f7749c8f7561068daafe3548b28 +2026-07-29-dsh-source-launch-tsx-esm.md: 5cf4a227f388a1ac8315594af4e0256864ef17f5 +2026-07-29-dsh-source-launch-tsx-esm.zh.md: b5a52b3d01840337c0091310e50d8fac34245519 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md index ed22e51d59..5cf4a227f3 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md @@ -35,4 +35,4 @@ The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (` - One launch vector across the whole engines range, including future Node lines that change native TypeScript support; the smoke gate enforces it per matrix line. - TypeScript transformation is delegated to tsx/esbuild again, reversing the prior note's goal of proving Node-native transformation; that goal is unreachable while vendored sources use non-erasable syntax and Node ships no transform mode. - The runtime declared-dependency enforcement in source launches is gone; undeclared workspace imports now surface only through static gates or built-mode resolution failures. -- Startup improves ~0.4s over the full tsx default (`demo:headless` now aliases the same `dsh run` source launch; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path). +- Startup improves ~0.4s over the full tsx default (`demo:headless` now aliases the same `dsh --profile headless` source launch; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path). diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md index bdd549092e..b5a52b3d01 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md @@ -35,4 +35,4 @@ node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(` - 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。 - TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 Agent Note「证明 Node 原生转换可用」的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。 - 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。 -- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 现为同一条 `dsh run` 源码启动命令的别名;ACP 保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。 +- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 现为同一条 `dsh --profile headless` 源码启动命令的别名;ACP 保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index baee9e065f..c583d6f373 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 385977b2d085a39bcda89bca0fb6543f08e7a961 -2026-08-05-profile-plugin-bundles.zh.md: 22ed4100b97db3f7c48bf55688f1a78edb512add +2026-08-05-profile-plugin-bundles.md: 54626e3f48a2ba7db19813e6e883f0e77499d0e2 +2026-08-05-profile-plugin-bundles.zh.md: 357e0f63d4eba0f0985c9e14aad54595c7b41c77 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 385977b2d0..54626e3f48 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -22,7 +22,7 @@ Two supporting refactors: the webserver's built-in static dist serving became th - **Dependency-scan plus partial `patchOrder`** (the original sketch): scanning `dependencies` for bundles and ordering unlisted ones alphabetically has two sources of truth and an implicit tie-break; one explicit ordered `dsh.profile.bundles` list is smaller and fully deterministic. A raw `pnpm add` inside the profile installs a library without activating any patch — explicit, no spooky scan. - **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony. -- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows the launcher patches, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` host hook is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts. +- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows and app-owned startup services, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` host hook is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts. - **Transitive bundle auto-application**: only direct `dsh.profile.bundles` entries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index 22ed4100b9..357e0f63d4 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -22,7 +22,7 @@ Status: implemented - **依赖扫描加部分 `patchOrder`**(最初的草案):扫描 `dependencies` 找出组合包、未列出者按字母序排列,会产生两个真源和一条隐式决胜规则;一份显式有序的 `dsh.profile.bundles` 列表更小、完全确定。在 profile 内直接 `pnpm add` 只会安装一个库,不激活任何 patch——行为显式,没有暗中扫描。 - **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证(「组合包来自安装目录」),且没有这些繁文缛节。 -- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是启动器 patch 的普通配置行,因此组合始终可完整 dump,manifest 保持纯数据。启动器持有的 `ctx.headlessIo` 宿主钩子是唯一由宿主提供的 slot,且在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。 +- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是普通配置行和由应用持有的启动服务,因此组合始终可完整 dump,manifest 保持纯数据。启动器持有的 `ctx.headlessIo` 宿主钩子是唯一由宿主提供的 slot,且在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。 - **组合包的传递式自动应用**:只有直接列在 `dsh.profile.bundles` 中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。 ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 728edb1ec0..97b4a529f3 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 269f9193e6cf7852ba9652c961bfdd309080ae0b -2026-08-06-app-owned-command-line.zh.md: 943932062983622267f28591dcc22ca2d12274e0 +2026-08-06-app-owned-command-line.md: 948de243abe39c7b4af014f8709e102a53aa9797 +2026-08-06-app-owned-command-line.zh.md: 00cce42d123c788f78386a718f7711cad0e0c234 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 269f9193e6..948de243ab 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -29,7 +29,7 @@ Four framework facts shape the mechanism: - **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the waterfall, HMR carries the raw config to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services. - **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and an active row enables it (`dsh web --dev` and its reload chain); the enabled row then follows ordinary injection ordering. -This puts dependency ordering at the seam that owns it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services. +This leaves dependency ordering in Cordis activation and Loader interpolation, which own it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services. ## Alternatives considered @@ -37,7 +37,7 @@ This puts dependency ordering at the seam that owns it. Rows keep their `inject` - **Releasing rows by clearing their `inject`**: it worked in isolation and failed on the real web tree, because clearing `inject` is exactly what loses the plugin's static injections. The failure is silent until a plugin reads a service it declared. - **Launcher-managed two-pass mounting**: it can make a provider active before readers are applied, but duplicates the composition, makes ordering a launcher concern, and conceals the Loader defect that nested expressions were evaluated in the include context rather than the target row's injected context. - **The launcher running each bundle's startup function before boot** (no cordis involvement): strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. Using a `cmdlineArgs`-injected startup row keeps one protocol: it is an ordinary row, dumpable and patchable, and a layering bundle disables it like any other. -- **Both apps parsing the same argv** (the one-shot bundle rides over the web bundle): two parsers cannot both own `-h`. A composition has exactly one command-line owner: the layering bundle disables the underlying startup row and names both startup services, so the absorbed rows start on their composed values. +- **Both apps parsing the same argv** (a custom composition combines Web and one-shot startup rows): two parsers cannot both own `-h`. A composition has exactly one command-line owner, so a layering bundle disables the startup row it absorbs and provides every startup service its retained rows inject. - **`instanceof CommanderError`**: an out-of-tree plugin brings its own commander copy, so the class identity differs and a printed `--help` was rethrown as a fatal load failure. Commander's control-flow errors are detected structurally instead. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index 9439320629..00cce42d12 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -29,7 +29,7 @@ boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Lo - **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfall,HMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。 - **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路);启用后的行继续遵循普通注入顺序。 -这样,依赖顺序就由真正持有它的接缝负责。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。 +这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。 ## 曾考虑的替代方案 @@ -37,7 +37,7 @@ boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Lo - **通过清空行的 `inject` 来放行**:孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 - **由启动器管理两趟挂载**:它可以让提供方先于读取行激活,但会重复组合、把顺序变成启动器职责,还掩盖了 Loader 的缺陷——嵌套表达式在 include 上下文而不是目标行的注入上下文中求值。 - **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。使用注入 `cmdlineArgs` 的启动行则只保留一套协议:它就是一个普通的行,可 dump、可 patch,叠加的组合包也能像禁用其他行那样禁用它。 -- **两个应用解析同一份 argv**(一次性组合包叠加在 web 组合包之上):两个解析器不可能同时持有 `-h`。一套组合有且只有一个命令行所有者:叠加的组合包禁用下层的启动行,并同时提供这两个启动服务,使被吸收的行按组合后的取值启动。 +- **两个应用解析同一份 argv**(自定义组合同时包含 Web 与一次性启动行):两个解析器不可能同时持有 `-h`。一套组合有且只有一个命令行所有者,因此叠加的组合包要禁用被吸收的启动行,并提供保留下来的各行所注入的全部启动服务。 - **`instanceof CommanderError`**:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的 `--help` 会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml index b851627050..989c195965 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md -2026-08-09-headless-direct-core-entry-point.md: 49afe2993de7302adbedcdf9e8e2347d6424ee2a -2026-08-09-headless-direct-core-entry-point.zh.md: 73c1cbe5ac777025f63f46751b1d5ccebbfe9676 +2026-08-09-headless-direct-core-entry-point.md: e411214a666787ff62626728c4e6887bfc3ec311 +2026-08-09-headless-direct-core-entry-point.zh.md: d17aab2352c9f55b58e52856ebb83cd6351afb10 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md index 49afe2993d..e411214a66 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md @@ -20,11 +20,11 @@ The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The headl `loadProfile` recognizes the exact installation-owned headless tuple (`dsh-base`, `dsh-web-app`, `dsh-headless`) and normalizes it to the shipped headless template while preserving every other manifest field. Extra, missing, or reordered bundle lists are user-owned and remain untouched. -This note owns the headless transport and completion contracts. [`dsh run` owns one-shot headless execution](../feature/2026-08-08-dsh-run-headless-command.md) owns the command grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. +This note owns the headless transport and completion contracts. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. ## Verification -Package tests use the real Session store and Agent registry around a scripted Agent factory to pin idle-to-idle aggregation, late asynchronous completion, terminal model diagnostics, other non-completed exits, direct failures, Loader-time disposal, and flush-before-exit ordering. The keyless assembled snapshots drive `dsh run` through a replayed tool round trip, record a `user/message` with `source.kind: 'user'`, and expose a terminal model failure on stderr. Built-bin acceptance reaches a mock provider through the published entry and requires final text on stdout, exit 0, and empty stderr. Config-dump acceptance excludes every Host, Web, and Client package from the shipped headless tree; PTY shutdown coverage requires no observation line and bounded disposal. +Package tests use the real Session store and Agent registry around a scripted Agent factory to pin idle-to-idle aggregation, late asynchronous completion, terminal model diagnostics, other non-completed exits, direct failures, Loader-time disposal, and flush-before-exit ordering. The keyless assembled snapshots drive `dsh --profile headless` through a replayed tool round trip, record a `user/message` with `source.kind: 'user'`, and expose a terminal model failure on stderr. Built-bin acceptance reaches a mock provider through the published entry and requires final text on stdout, exit 0, and empty stderr. Config-dump acceptance excludes every Host, Web, and Client package from the shipped headless tree; PTY shutdown coverage requires no observation line and bounded disposal. ## Alternatives considered @@ -39,6 +39,6 @@ Package tests use the real Session store and Agent registry around a scripted Ag ## Consequences -`dsh run` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Successful stderr is empty, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no ApiProxy `rpcId`. +`dsh --profile headless` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Successful stderr is empty, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no ApiProxy `rpcId`. ApiProxy carrier coverage stays in the ApiProxy package. Custom one-shot profiles may include Host or Web bundles explicitly, while the shipped profile and the recognized installation-owned tuple are Web-free. diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md index 73c1cbe5ac..d17aab2352 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md @@ -20,11 +20,11 @@ Status: implemented `loadProfile` 识别安装过程拥有的精确 headless 元组(`dsh-base`、`dsh-web-app`、`dsh-headless`),将其规范化为随附的 headless 模板,并保留 manifest(元数据清单)的其他所有字段。带额外项、缺少项或顺序不同的组合包列表归用户所有,保持不变。 -本 Agent Note 负责 headless 的传输与完成约定。[`dsh run` 负责一次性 headless 执行](../feature/2026-08-08-dsh-run-headless-command.md)负责命令语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.md)负责共享 Agent 默认值的持久化。 +本 Agent Note 负责 headless 的传输与完成约定。[应用持有自己的命令行](2026-08-06-app-owned-command-line.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.md)负责共享 Agent 默认值的持久化。 ## 验证 -包测试围绕脚本化 Agent 工厂使用真实的会话存储与 Agent 注册表,固定空闲态到空闲态的聚合、延迟异步完成、终止态模型诊断、其他未完成退出、直接失败、Loader 加载期间的 dispose(资源释放),以及退出前 flush 的顺序。组装后的无密钥快照通过回放的工具往返驱动 `dsh run`,记录一条带 `source.kind: 'user'` 的 `user/message`,并在 stderr 暴露终止态模型失败。构建后二进制验收通过已发布入口访问 mock 提供方,并要求最终文本出现在 stdout、退出状态为 0 且 stderr 为空。配置转储验收排除随附 headless 树中的所有 Host、Web 与 Client 包;PTY 关闭覆盖要求不出现观察行,并在有界时间内完成 dispose。 +包测试围绕脚本化 Agent 工厂使用真实的会话存储与 Agent 注册表,固定空闲态到空闲态的聚合、延迟异步完成、终止态模型诊断、其他未完成退出、直接失败、Loader 加载期间的 dispose(资源释放),以及退出前 flush 的顺序。组装后的无密钥快照通过回放的工具往返驱动 `dsh --profile headless`,记录一条带 `source.kind: 'user'` 的 `user/message`,并在 stderr 暴露终止态模型失败。构建后二进制验收通过已发布入口访问 mock 提供方,并要求最终文本出现在 stdout、退出状态为 0 且 stderr 为空。配置转储验收排除随附 headless 树中的所有 Host、Web 与 Client 包;PTY 关闭覆盖要求不出现观察行,并在有界时间内完成 dispose。 ## 考虑过的替代方案 @@ -39,6 +39,6 @@ Status: implemented ## 后果 -`dsh run` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。成功时 stderr 为空,完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带 ApiProxy `rpcId`。 +`dsh --profile headless` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。成功时 stderr 为空,完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带 ApiProxy `rpcId`。 ApiProxy 载体覆盖保留在 ApiProxy 包中。自定义一次性 profile 可以显式包含 Host 或 Web 组合包;随附 profile 与可识别的安装过程所属元组均不含 Web。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml index 34f0f1457e..e4ff6a9cfa 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md -2026-08-03-cli-signal-shutdown-escalation.md: 55917400fac2728d13dc2cdd799a7e234b6ed661 -2026-08-03-cli-signal-shutdown-escalation.zh.md: c7897a8d77e8c2ebad43cec4e12170b04c837350 +2026-08-03-cli-signal-shutdown-escalation.md: 173d06482cd8a1fcbb985763cc313e3f9b170bc6 +2026-08-03-cli-signal-shutdown-escalation.zh.md: efa52524199906cf636cb2b55cb4249857dc1348 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md index 55917400fa..173d06482c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md @@ -6,7 +6,7 @@ English | [中文](2026-08-03-cli-signal-shutdown-escalation.zh.md) ## Problem -The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and the headless command (now `dsh run`) so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. +The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and the headless command (now `dsh --profile headless`) so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. A user then reproduced the headless command hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts. diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md index c7897a8d77..efa5252419 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -默认挂载遥测后,`dsh web` 与 headless 命令(现为 `dsh run`)新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 +默认挂载遥测后,`dsh web` 与 headless 命令(现为 `dsh --profile headless`)新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 随后有用户复现,headless 命令在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测,而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promise;OTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此,代理/沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml index 730f57e681..7b9076e9b4 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md -2026-08-08-dsh-run-headless-command.md: ed095f4077a23e51bffb647d24eed19ba09e11ed -2026-08-08-dsh-run-headless-command.zh.md: 89d54e35573f14786e05d648f2b42891ca27a043 +2026-08-08-dsh-run-headless-command.md: 779e568790a58899488ea87292c1bc2db329617f +2026-08-08-dsh-run-headless-command.zh.md: 5a21033e921cb181aa6987259d37b4bc5004e2d9 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md index ed095f4077..779e568790 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-08-08-dsh-run-headless-command.zh.md) +> **Superseded command grammar.** [Apps now own their command lines](../architecture/2026-08-06-app-owned-command-line.md): the headless startup row parses the task from `dsh --profile headless <task...>`, and the launcher no longer has a `run` invocation or patches task text into rows. This note remains the rejected launcher-owned design context; the direct execution and completion contract it selected remains current in [headless is a direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md). + ## Problem Generic profile boot and one-shot task execution have different lifecycle contracts. A root grammar that accepts optional task text makes one argv shape mean either a long-lived process or a terminating task according to a plugin row discovered only after composition. It also exposes a profile implementation detail as the primary user command and gives custom profiles no explicit one-shot entry. diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md index 89d54e3557..5a21033e92 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-08-08-dsh-run-headless-command.md) | 中文 +> **命令语法已被取代。** [应用现在持有自己的命令行](../architecture/2026-08-06-app-owned-command-line.md):headless 启动行从 `dsh --profile headless <task...>` 解析任务,启动器不再包含 `run` 调用,也不再把任务文本 patch 进配置行。本笔记保留被否决的启动器持有设计背景;它选定的直接执行与完成约定仍由 [headless 是直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md)持有。 + ## 问题 通用 profile 启动与一次性任务执行具有不同的生命周期约定。若根语法接受可选任务文本,同一种 argv 形态会表示常驻进程或终止式任务,具体含义取决于组合完成后才发现的插件配置行。它还会把 profile 实现细节暴露成主要用户命令,并使自定义 profile 缺少明确的一次性入口。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index dcef1b190f..b23fcae758 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md -2026-07-20-remove-stdio-and-echo-agents.md: 256e626f4ff41016d0227eef7cc3e4e51e15058b -2026-07-20-remove-stdio-and-echo-agents.zh.md: 013135eff5d1dbbc570561e70c751ff4de289989 +2026-07-20-remove-stdio-and-echo-agents.md: 23fcb90599c2ff96cd7ac0e6f7ee8fd508a6d1ad +2026-07-20-remove-stdio-and-echo-agents.zh.md: 7aabf5612a54245327da9af804f745237472cb88 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index 256e626f4f..23fcb90599 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -19,7 +19,7 @@ The stdio and Echo agents are removed without compatibility packages, modes, com The remaining application roles are explicit: - `@deepseek-ai/dsh-tui` owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/config/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with PTY plus terminal-snapshot coverage in `apps/cli/tests/`. -- [`dsh run`](../../../../apps/cli/README.md) owns non-interactive execution. Its `headless` profile is the product composition; `examples/headless-agent` owns replay snapshots, generic real-agent suites, and an unexported keyless Loader driver. +- [`dsh --profile headless`](../../../../apps/cli/README.md) owns non-interactive execution. Its `headless` profile is the product composition; `examples/headless-agent` owns replay snapshots, generic real-agent suites, and an unexported keyless Loader driver. - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations. The SDK project model and create/config workflows replace the `stdio` run-interface option with `tui`; generated TUI projects compose `@deepseek-ai/dsh-tui` and create or resume one exact session. Repository-facing demo documentation requires a DeepSeek API key and leads with the real Headless or TUI agents. @@ -30,7 +30,7 @@ Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapt TUI and Headless Loader coverage run the real app packages in source and built modes. PTY-driven subprocess coverage is reserved for the TUI lifecycle; other entry-point smokes use the one-shot pipe protocol. Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, SDK-interface, `createStdioChat`, and `StdioRuntime` references. -The built `dsh` bin rejects a piped TUI launch before Loader boot and points at `dsh run`; `apps/cli/tests/built-bin.e2e.ts` pins the product one-shot entry under plain Node, including output and invalid arguments. `examples/headless-agent/tests/headless.snapshot.ts` pins product persistence, while `apps/cli/tests/headless-shutdown.e2e.ts` owns bounded signal escalation. The headless example's test-only JSONL driver preserves assembled canonical-event snapshots without creating a second CLI contract. Code Mode has programmatic TUI snapshots and an ACP overlay demo. Time-context integration uses the explicit Headless test composition for two ordered turns, while its package tests own finer elapsed-time behavior. +The built `dsh` bin rejects a piped TUI launch before Loader boot and points at `dsh --profile headless`; `apps/cli/tests/built-bin.e2e.ts` pins the product one-shot entry under plain Node, including output and invalid arguments. `examples/headless-agent/tests/headless.snapshot.ts` pins product persistence, while `apps/cli/tests/headless-shutdown.e2e.ts` owns bounded signal escalation. The headless example's test-only JSONL driver preserves assembled canonical-event snapshots without creating a second CLI contract. Code Mode has programmatic TUI snapshots and an ACP overlay demo. Time-context integration uses the explicit Headless test composition for two ordered turns, while its package tests own finer elapsed-time behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index 013135eff5..7aabf5612a 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -19,7 +19,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 保留的应用角色均有明确归属: - `@deepseek-ai/dsh-tui` 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`apps/cli/config/base.cordis.yml` 与 `tui.cordis.yml` overlay 拥有完整 coding 组装,PTY 与终端快照覆盖则位于 `apps/cli/tests/`。 -- [`dsh run`](../../../../apps/cli/README.md) 负责非交互式执行。其 `headless` profile 是产品组装;`examples/headless-agent` 负责回放快照、通用真实 agent 测试套件和未导出的无密钥 Loader driver。 +- [`dsh --profile headless`](../../../../apps/cli/README.md) 负责非交互式执行。其 `headless` profile 是产品组装;`examples/headless-agent` 负责回放快照、通用真实 agent 测试套件和未导出的无密钥 Loader driver。 - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。 SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换为 `tui`;生成的 TUI 工程组合 `@deepseek-ai/dsh-tui`,并创建或恢复一个确切会话。仓库中的演示文档要求 DeepSeek API key,并优先引导到真实的 Headless 或 TUI agent。 @@ -30,7 +30,7 @@ SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换 TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。由 PTY 驱动的子进程覆盖仅用于 TUI 生命周期;其他入口冒烟测试使用单次管道协议。Headless 验证任务/结果约定和工具调用约定。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点、SDK 接口、`createStdioChat` 和 `StdioRuntime` 引用。 -构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向 `dsh run`;`apps/cli/tests/built-bin.e2e.ts` 在普通 Node 下固定产品的一次性入口,包括输出和无效参数。`examples/headless-agent/tests/headless.snapshot.ts` 固定产品持久化,`apps/cli/tests/headless-shutdown.e2e.ts` 则负责有界信号升级。headless 示例仅供测试的 JSONL driver 保留组装后的规范事件快照,而不会创建第二套 CLI(命令行界面)约定。Code Mode 由程序化 TUI 快照与 ACP overlay demo 覆盖。时间上下文集成通过显式的 Headless 测试组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 +构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向 `dsh --profile headless`;`apps/cli/tests/built-bin.e2e.ts` 在普通 Node 下固定产品的一次性入口,包括输出和无效参数。`examples/headless-agent/tests/headless.snapshot.ts` 固定产品持久化,`apps/cli/tests/headless-shutdown.e2e.ts` 则负责有界信号升级。headless 示例仅供测试的 JSONL driver 保留组装后的规范事件快照,而不会创建第二套 CLI(命令行界面)约定。Code Mode 由程序化 TUI 快照与 ACP overlay demo 覆盖。时间上下文集成通过显式的 Headless 测试组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml index 217265168d..fac9ae9452 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md -2026-08-08-remove-cli-demo.md: 403e01f94c976d2d17eb391830721b31675cd6a9 -2026-08-08-remove-cli-demo.zh.md: 7f11e0c17a15454b99b32d14ea6eda177f4b01f6 +2026-08-08-remove-cli-demo.md: 31879e8284daf5f34af6731b09fce743f9eb5391 +2026-08-08-remove-cli-demo.zh.md: d4dfef1ba8c4d27cd667e319519cfa1ace74baef diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md index 403e01f94c..31879e8284 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md @@ -6,29 +6,29 @@ English | [中文](2026-08-08-remove-cli-demo.zh.md) ## Problem -After [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two entry points also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. +After [`dsh --profile headless`](../architecture/2026-08-06-app-owned-command-line.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two entry points also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. The replay suites still need canonical session events to pin assembled backend behavior. That testing need does not require a published command or compatibility contract. ## Decision -Delete `@deepseek-ai/dsh-cli-demo` completely: its package, bin, parser, app plugin, output formats, tests, workspace references, generated-catalog entries, and active documentation. No alias or compatibility package remains. The root `demo:headless` script is retained only as a direct alias of `dsh run`; the product command owns final-text stdout, the observation URL on stderr, persistence, exit status, and shutdown. +Delete `@deepseek-ai/dsh-cli-demo` completely: its package, bin, parser, app plugin, output formats, tests, workspace references, generated-catalog entries, and active documentation. No alias or compatibility package remains. The root `demo:headless` script is retained only as a direct alias of `dsh --profile headless`; the product command owns final-text stdout, failure diagnostics on stderr, persistence, exit status, and shutdown. `examples/headless-agent` becomes an explicit test composition. Its Loader configs mount `@deepseek-ai/dsh-agent-spine-demo`, one root agent, JSONL persistence, and checkpoint policy as separate rows instead of hiding them behind an app bundle. The support-tier `@deepseek-ai/dsh-loader-smoke` package owns the shared direct-agent turn helper; unexported example-local drivers select their Loader configuration and render canonical events as JSONL. They are launched only by tests, have no bin, and do not define a supported product output format. ## Alternatives considered -- **Keep `dsh-cli-demo` as an alias or wrapper around `dsh run`.** Rejected because a second bin and package would preserve two discoverable owners without adding capability. -- **Move JSON and stream-JSON flags onto `dsh run`.** Rejected because no current product consumer requires them; adopting the old demo protocol would enlarge the canonical CLI contract solely to save test machinery. +- **Keep `dsh-cli-demo` as an alias or wrapper around `dsh --profile headless`.** Rejected because a second bin and package would preserve two discoverable owners without adding capability. +- **Move JSON and stream-JSON flags onto `dsh --profile headless`.** Rejected because no current product consumer requires them; adopting the old demo protocol would enlarge the canonical CLI contract solely to save test machinery. - **Delete the canonical-event snapshots with the package.** Rejected because they pin model-visible assembled behavior that final-text product acceptance cannot observe. - **Keep the app plugin but delete only its bin.** Rejected because the hidden composition would still duplicate the explicit headless profile and conceal which services the test leaf mounts. ## Consequences -This is intentionally breaking. `dsh-cli-demo`, its `--output-format` choices, and imports from `@deepseek-ai/dsh-cli-demo/src/cli.ts` no longer resolve. There is no public event-stream replacement in this change; callers use `dsh run` for one-shot execution and must choose an existing protocol surface when they need structured automation. +This is intentionally breaking. `dsh-cli-demo`, its `--output-format` choices, and imports from `@deepseek-ai/dsh-cli-demo/src/cli.ts` no longer resolve. There is no public event-stream replacement in this change; callers use `dsh --profile headless` for one-shot execution and must choose an existing protocol surface when they need structured automation. -The repository retains backend replay coverage through test-only infrastructure, while product smoke and built-bin acceptance exercise `dsh run`. A separate one-shot package may return only if it owns a genuinely independent, versioned protocol that cannot belong to the product launcher; a second spelling or output shim is not enough. +The repository retains backend replay coverage through test-only infrastructure, while product smoke and built-bin acceptance exercise `dsh --profile headless`. A separate one-shot package may return only if it owns a genuinely independent, versioned protocol that cannot belong to the product launcher; a second spelling or output shim is not enough. ## Verification -Focused Loader smokes cover the explicit composition in source and plain-Node built modes, snapshot tests diff its canonical JSONL and persisted logs, product acceptance covers `dsh run`, and documentation plus generated graph/catalog gates reject live references to the removed package. The frozen Agent Note archive remains historical evidence and is not rewritten. +Focused Loader smokes cover the explicit composition in source and plain-Node built modes, snapshot tests diff its canonical JSONL and persisted logs, product acceptance covers `dsh --profile headless`, and documentation plus generated graph/catalog gates reject live references to the removed package. The frozen Agent Note archive remains historical evidence and is not rewritten. diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md index 7f11e0c17a..d4dfef1ba8 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md @@ -6,29 +6,29 @@ Status: implemented ## 问题 -在 [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) 成为产品的一次性命令后,`@deepseek-ai/dsh-cli-demo` 仍是承担同一工作的第二个应用包。它另行拥有一套可执行文件、参数语法、应用组装、取消生命周期、文本/JSON/stream-JSON 输出约定、构建产物、配套文档和测试套件。两个入口组装的树也不相同,因此 demo 成功不能证明已交付的 `headless` profile 可用,用户还必须在功能重叠的命令之间作出选择。 +在 [`dsh --profile headless`](../architecture/2026-08-06-app-owned-command-line.md) 成为产品的一次性命令后,`@deepseek-ai/dsh-cli-demo` 仍是承担同一工作的第二个应用包。它另行拥有一套可执行文件、参数语法、应用组装、取消生命周期、文本/JSON/stream-JSON 输出约定、构建产物、配套文档和测试套件。两个入口组装的树也不相同,因此 demo 成功不能证明已交付的 `headless` profile 可用,用户还必须在功能重叠的命令之间作出选择。 回放套件仍需要规范会话事件来固定组装后的后端行为。这一测试需求不需要已发布命令或兼容性约定。 ## 决策 -彻底删除 `@deepseek-ai/dsh-cli-demo`:包括它的包、bin、解析器、应用插件、输出格式、测试、workspace 引用、生成目录条目和现行文档。不保留别名或兼容包。根目录的 `demo:headless` 脚本仅作为 `dsh run` 的直接别名保留;stdout 上的最终文本、stderr 上的观察 URL、持久化、退出状态和关闭行为均由产品命令负责。 +彻底删除 `@deepseek-ai/dsh-cli-demo`:包括它的包、bin、解析器、应用插件、输出格式、测试、workspace 引用、生成目录条目和现行文档。不保留别名或兼容包。根目录的 `demo:headless` 脚本仅作为 `dsh --profile headless` 的直接别名保留;stdout 上的最终文本、stderr 上的失败诊断、持久化、退出状态和关闭行为均由产品命令负责。 `examples/headless-agent` 成为显式测试组装。其 Loader 配置把 `@deepseek-ai/dsh-agent-spine-demo`、一个根 agent(智能体)、JSONL 持久化和检查点策略挂载为独立配置行,不再将其隐藏在应用组合包之后。支持层的 `@deepseek-ai/dsh-loader-smoke` 包负责共享的直接 agent 轮次 helper;未导出的示例本地 driver 选择各自的 Loader 配置,并将规范事件渲染为 JSONL。这些 driver 只由测试启动,不提供 bin,也不定义受支持的产品输出格式。 ## 考虑过的替代方案 -- **保留 `dsh-cli-demo` 作为 `dsh run` 的别名或包装层。** 不予采纳:第二个 bin 和包会让同一功能继续存在两个可发现的归属方,却没有增加任何能力。 -- **把 JSON 和 stream-JSON 标志移到 `dsh run`。** 不予采纳:当前没有产品消费方需要这些标志;沿用旧 demo 协议,只会为了保留测试机制而扩大规范 CLI(命令行界面)约定。 +- **保留 `dsh-cli-demo` 作为 `dsh --profile headless` 的别名或包装层。** 不予采纳:第二个 bin 和包会让同一功能继续存在两个可发现的归属方,却没有增加任何能力。 +- **把 JSON 和 stream-JSON 标志移到 `dsh --profile headless`。** 不予采纳:当前没有产品消费方需要这些标志;沿用旧 demo 协议,只会为了保留测试机制而扩大规范 CLI(命令行界面)约定。 - **随包一并删除规范事件快照。** 不予采纳:这些快照固定了模型可见的组装行为,而只检查最终文本的产品验收无法观察这些行为。 - **保留应用插件,只删除它的 bin。** 不予采纳:隐藏的组装仍会重复显式的 headless profile,并掩盖测试叶节点挂载了哪些服务。 ## 后果 -这是有意为之的破坏性变更。`dsh-cli-demo`、它的 `--output-format` 选项以及对 `@deepseek-ai/dsh-cli-demo/src/cli.ts` 的导入都不再可解析。本变更不提供公开的事件流替代接口;调用方使用 `dsh run` 执行一次性任务,需要结构化自动化时则必须选择现有的协议接口。 +这是有意为之的破坏性变更。`dsh-cli-demo`、它的 `--output-format` 选项以及对 `@deepseek-ai/dsh-cli-demo/src/cli.ts` 的导入都不再可解析。本变更不提供公开的事件流替代接口;调用方使用 `dsh --profile headless` 执行一次性任务,需要结构化自动化时则必须选择现有的协议接口。 -仓库通过仅供测试的基础设施保留后端回放覆盖,产品冒烟测试和 built-bin 验收则运行 `dsh run`。只有当独立的一次性包负责一套真正独立、带版本且不能归产品启动器所有的协议时,它才可以重新引入;第二种命令写法或输出 shim 并不足以构成理由。 +仓库通过仅供测试的基础设施保留后端回放覆盖,产品冒烟测试和 built-bin 验收则运行 `dsh --profile headless`。只有当独立的一次性包负责一套真正独立、带版本且不能归产品启动器所有的协议时,它才可以重新引入;第二种命令写法或输出 shim 并不足以构成理由。 ## 验证 -聚焦的 Loader 冒烟测试在源码模式和由普通 Node 启动的构建模式下覆盖显式组装,快照测试对比其规范 JSONL 和持久化日志,产品验收覆盖 `dsh run`,文档检查及生成图谱/目录门禁则拒绝对已移除包的活跃引用。冻结的 Agent Note 归档保留为历史证据,不会被重写。 +聚焦的 Loader 冒烟测试在源码模式和由普通 Node 启动的构建模式下覆盖显式组装,快照测试对比其规范 JSONL 和持久化日志,产品验收覆盖 `dsh --profile headless`,文档检查及生成图谱/目录门禁则拒绝对已移除包的活跃引用。冻结的 Agent Note 归档保留为历史证据,不会被重写。 diff --git a/README.i18n.yaml b/README.i18n.yaml index 35cb82e776..43185dc239 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: 3174630d021b3868986d6ad9989d257fe8ac29fb -README.zh.md: 377ea6372a9a531c1400d08b0ef33792b452dc65 +README.md: 9b8944027572072e08a39bd9e482996f8128224c +README.zh.md: a1b2b6a36c4baac8a49b88a42c9f17c986287ff1 diff --git a/README.md b/README.md index 3174630d02..9b89440275 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer Run one task, print the final answer, and exit: ```sh -dsh run "summarize this workspace" +dsh --profile headless "summarize this workspace" ``` ### Automation and SDKs diff --git a/README.zh.md b/README.zh.md index 377ea6372a..a1b2b6a36c 100644 --- a/README.zh.md +++ b/README.zh.md @@ -56,7 +56,7 @@ profile 布局、层语义与配置输出命令详见 [CLI(命令行界面) 运行一项任务,打印最终答案后退出: ```sh -dsh run "summarize this workspace" +dsh --profile headless "summarize this workspace" ``` ### 自动化与 SDK diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index b4c933291e..67dd461179 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 86d890ebec7121a9f8431f52789b8346ba59deb2 -README.zh.md: 80b9a6d56bdb49f72d25f7485662a6814f5184a3 +README.md: 96c6932a1faf6f5ce9b64e0390e2a4b3dcb55fc4 +README.zh.md: ea80985a8f6ea43bcea45dfe169937388ab25df0 diff --git a/apps/cli/README.md b/apps/cli/README.md index 86d890ebec..96c6932a1f 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -17,7 +17,7 @@ The invoking directory is the default workspace root. The `web` and `headless` p ## App arguments -The launcher parses only its own flags and hands everything after them to the booted profile, where that app's own startup row parses them ([`dsh-cmdline`](../../packages/ui/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments: +The launcher parses only its own flags and hands everything after them to the booted profile, where that app's own startup row parses them ([`dsh-cmdline`](../../packages/boot/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments: ```sh dsh --profile web --port 8080 # --port belongs to the web app diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 80b9a6d56b..ea80985a8f 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -17,7 +17,7 @@ ## 应用参数 -启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../packages/ui/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点: +启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../packages/boot/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点: ```sh dsh --profile web --port 8080 # --port belongs to the web app diff --git a/apps/cli/package.json b/apps/cli/package.json index 4442006b0a..8b50f180f2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 9916a9eeb3..b6c1ea5ab9 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 13c0d000eec045cc34f2b7eb5fe5ba9ac9ed557e -README.zh.md: 5392db3220013a50040bf59f212040e8d0291037 +README.md: b4a8dfe8a0473e69a0c82e33aba2d1f4210a2477 +README.zh.md: 287a215b6abb31c7f0375987210eb9703acf5657 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 13c0d000ee..b4a8dfe8a0 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -14,7 +14,7 @@ The `web` and `headless` profiles auto-initialize from shipped templates on firs ### App arguments -The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where that app's own startup row parses it ([`dsh-cmdline`](../../../packages/ui/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary. +The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where that app's own startup row parses it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary. A composition mounts once. A Loader row that injects `cmdlineArgs` parses this app's arguments and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the startup service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port. @@ -24,7 +24,7 @@ The shipped apps own these command lines: | Profile | Arguments | |---|---| -| `web` | `--host`, `--port`, `--dev`, `--workspace-root`, repeatable `--trusted-host` | +| `web` | `--host`, `--port`, `--dev`, repeatable `--trusted-host` | | `headless` | the task text, as the positional argument | A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. @@ -52,7 +52,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, which owns them in its bundle's startup row. `--host`, `--port`, and `--workspace-root` override the composed values of the rows that carry them, repeatable `--trusted-host` adds authorities over the composed fence configuration, and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, which owns them in its bundle's startup row. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` adds authorities over the composed fence configuration, and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 5392db3220..287a215b6a 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -14,7 +14,7 @@ ### 应用参数 -启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../../packages/ui/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。 +启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../../packages/boot/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。 一套组合只挂载一次。注入 `cmdlineArgs` 的 Loader 行解析本应用的参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖启动服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。 @@ -24,7 +24,7 @@ | Profile | 参数 | |---|---| -| `web` | `--host`、`--port`、`--dev`、`--workspace-root`、可重复的 `--trusted-host` | +| `web` | `--host`、`--port`、`--dev`、可重复的 `--trusted-host` | | `headless` | 任务文本,作为位置参数 | 一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 @@ -52,7 +52,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由该应用在其组合包的启动行中持有。`--host`、`--port` 和 `--workspace-root` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 在组合出的围栏配置之上追加 authority,`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由该应用在其组合包的启动行中持有。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 在组合出的围栏配置之上追加 authority,`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index d0e8e9d138..9aa44f8b22 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { loadEnv } from '@deepseek-ai/dsh-app-boot' +import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot' import { parseDshArgs } from './args.ts' // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit @@ -24,13 +24,13 @@ function readVersion(): string { return typeof manifest.version === 'string' ? manifest.version : '0.0.0' } -loadEnv('dsh') const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'profile': { const { runProfile } = await import('./profile-boot.ts') await runProfile({ + environment: loadLayeredEnv('dsh'), profile: invocation.profile, patchFiles: invocation.patches, args: invocation.args, diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 5acebe9b44..a3159d4f49 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -195,6 +195,11 @@ export interface RunProfileOptions { prepare?: (ctx: Context) => Promise<void> | void } +/** Re-throw setup failures unless this invocation's signal already owns shutdown. */ +function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void { + if (!signal.aborted) throw error +} + /** * Boot one profile invocation end to end and leave process lifetime to the * mounted plugins (or to a one-shot runner the composition mounts). @@ -327,7 +332,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con compose: composeLive, }) } catch (error) { - if (!signalShutdown.signal.aborted) throw error + suppressSignalShutdownError(signalShutdown.signal, error) } } return { ctx, shutdown } diff --git a/apps/cli/tests/headless-shutdown.e2e.ts b/apps/cli/tests/headless-shutdown.e2e.ts index b0bcbb7cae..42f2aad709 100644 --- a/apps/cli/tests/headless-shutdown.e2e.ts +++ b/apps/cli/tests/headless-shutdown.e2e.ts @@ -83,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise<string> { ].join('\n')) const launch = resolveExampleLaunch({ srcBin: dshBinScript, - configArgs: ['run', 'never complete'], + configArgs: ['--profile', 'headless', 'never complete'], tsconfigPath, env: { DSH_HOME: home, diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index a288a5aa95..cecf7a4bb9 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -21,7 +21,7 @@ "path": "../../packages/boot/app-boot" }, { - "path": "../../packages/ui/cmdline" + "path": "../../packages/boot/cmdline" }, { "path": "../../packages/bundle/base" @@ -50,6 +50,9 @@ { "path": "../../packages/core/tools" }, + { + "path": "../../packages/util/environment" + }, { "path": "../../packages/util/paths" }, diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index d4e0df8b0e..e4f119da06 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: 64c7722a3cc3788d110eab8d9be161129684b942 -config-catalog.zh.md: 4cc146a38b408d9f1a7e5067419fead48520ae11 +config-catalog.md: 836a7f6a81f8c77be12fd10be5b8204be8c1acc0 +config-catalog.zh.md: 22dca1252d93ea9ce223464079f3c51c35eeb89d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7cd481e6d5..836a7f6a81 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -572,7 +572,7 @@ Source: [`packages/goal/goal/src/index.ts:116`](../packages/goal/goal/src/index. Requires: `agentDefaultModel` · `agents` · `sessions` ```ts config-catalog -/** Plugin config: the task, patched in by the launcher. */ +/** Plugin config: the task resolved from this app's injected startup service. */ export interface Config { /** The prompt text for the single run. */ task: string @@ -2520,21 +2520,21 @@ Source: [`packages/web/web/src/index.ts:55`](../packages/web/web/src/index.ts) Requires: `httpServer` ```ts config-catalog -/** Plugin config: the surface facts the launcher patches over this bundle's defaults. */ +/** Plugin config: composed deployment settings plus per-invocation startup values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode - /** Print the URL line on activation; a headless layer over this bundle turns it off. */ + /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** * Register the model-visible surface context (the `app:web-surface` prompt * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot - * layer turns it off: its user is not interacting through the GUI, so the + * non-interactive layer can turn it off when its user is not in the GUI, so the * orientation text would be false. */ surfaceContext: boolean /** - * LAN IPv4 addresses sampled once by the launcher when the effective bind + * LAN IPv4 addresses sampled once by the app startup row when the effective bind * is all-interfaces — the exact snapshot the /api trust fence was * configured with, so the printed LAN URL can never name an address the * fence rejects. Empty on a loopback bind. @@ -2546,7 +2546,7 @@ export interface Config { export type WebMode = 'production' | 'development' ``` -Source: [`packages/bundle/web-app/src/index.ts:36`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:41`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` @@ -2792,6 +2792,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) +- `@deepseek-ai/dsh-cmdline` ([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-environment` ([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/scaffold/helper/src/index.ts`](../packages/scaffold/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4cc146a38b..22dca1252d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -574,7 +574,7 @@ export interface Config { 需要:`agentDefaultModel` · `agents` · `sessions` ```ts config-catalog -/** Plugin config: the task, patched in by the launcher. */ +/** Plugin config: the task resolved from this app's injected startup service. */ export interface Config { /** The prompt text for the single run. */ task: string @@ -2521,21 +2521,21 @@ export interface WebServiceConfig { 需要:`httpServer` ```ts config-catalog -/** Plugin config: the surface facts the launcher patches over this bundle's defaults. */ +/** Plugin config: composed deployment settings plus per-invocation startup values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode - /** Print the URL line on activation; a headless layer over this bundle turns it off. */ + /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** * Register the model-visible surface context (the `app:web-surface` prompt * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot - * layer turns it off: its user is not interacting through the GUI, so the + * non-interactive layer can turn it off when its user is not in the GUI, so the * orientation text would be false. */ surfaceContext: boolean /** - * LAN IPv4 addresses sampled once by the launcher when the effective bind + * LAN IPv4 addresses sampled once by the app startup row when the effective bind * is all-interfaces — the exact snapshot the /api trust fence was * configured with, so the printed LAN URL can never name an address the * fence rejects. Empty on a loopback bind. @@ -2547,7 +2547,7 @@ export interface Config { export type WebMode = 'production' | 'development' ``` -来源:[`packages/bundle/web-app/src/index.ts:32`](../packages/bundle/web-app/src/index.ts) +来源:[`packages/bundle/web-app/src/index.ts:41`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` @@ -2792,6 +2792,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-slots`([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web`([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react`([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) +- `@deepseek-ai/dsh-cmdline`([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-environment`([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) - `@deepseek-ai/dsh-helper`([`packages/scaffold/helper/src/index.ts`](../packages/scaffold/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol`([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 2b608ec739..c3743e38c0 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/testing.md -testing.md: f5e8a478ec86c29c52f4127c51682c1c44fd23a7 -testing.zh.md: bd1fa7d23263d7c6e3bed65ef4ed09576ca47cc1 +testing.md: f330bb1e02f3613c63f3989a8f9128f737bf5c52 +testing.zh.md: db6facb4fa4bf07eda0a6ee7e558c8c60d4c331e diff --git a/docs/testing.md b/docs/testing.md index f5e8a478ec..f330bb1e02 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,7 +9,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent tests for contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/bash/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). -- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless backend scenarios boot their explicit example composition through an unexported JSONL test driver, while `apps/cli` separately owns product `dsh run` acceptance. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless backend scenarios boot their explicit example composition through an unexported JSONL test driver, while `apps/cli` separately owns product `dsh --profile headless` acceptance. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares replayed browser output with `apps/web/tests/snapshots/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` [builds first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) for plugin CSS. Committed session-format JSONL uses the canonical packed-row layout, and the keyless snapshot gate discovers every such fixture by its `session` header; the [temporary migrator](../scripts/migrate-packed-session-fixtures.ts) rewrites older fixture layouts. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index bd1fa7d232..db6facb4fa 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -9,7 +9,7 @@ - **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及针对约定回归的永久测试(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/bash/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其 executor 套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 -- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输约定与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 后端场景通过未导出的 JSONL 测试 driver 启动各自显式的示例组装,而 `apps/cli` 则单独负责产品 CLI(命令行界面)`dsh run` 的验收。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输约定与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 后端场景通过未导出的 JSONL 测试 driver 启动各自显式的示例组装,而 `apps/cli` 则单独负责产品 CLI(命令行界面)`dsh --profile headless` 的验收。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 - **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 将回放后的浏览器输出与 `apps/web/tests/snapshots/` 比较。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md))。`test:web` 会[先构建](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)以交付插件 CSS。 签入仓库的会话格式 JSONL 使用规范打包行布局,无密钥快照门禁会通过 `session` header 发现每一份此类 fixture;[临时迁移器](../scripts/migrate-packed-session-fixtures.ts)会改写旧版 fixture 布局。 diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index 963fe17378..ebcb75a3ca 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/publish.md -publish.md: c81e53d75ecccd31c9051f33252854dbe156c566 -publish.zh.md: c5a15be00bea838eb534dbf608c29d3832c2c0e1 +publish.md: 04520b0fb7d30c716e3c87761bd38f0c25824739 +publish.zh.md: 7b0e0141dc0522bb5ec356aa8cba1618c9517f09 diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index c81e53d75e..04520b0fb7 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -118,9 +118,19 @@ A bundle that defines a runnable app marks its startup row through the injection inject: [cmdlineArgs] ``` -That row calls `runStartup` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/ui/cmdline/README.md) with the app's own commander program. The launcher hands it every argument after the launcher flags, so app-specific flags need no launcher change. Loader mounts the composition once, waits for each row's injections, and only then evaluates that row's `!!js` config against its injected context. +That row calls `runStartup` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) with the app's own commander program. The launcher hands it every argument after the launcher flags, so app-specific flags need no launcher change. Loader mounts the composition once, waits for each row's injections, and only then evaluates that row's `!!js` config against its injected context. -Rows configured by those arguments inject the startup service and read it from their own `!!js` options, with the deployment value beside it as the fallback. On `--help`, the service is not provided, so those rows never activate. An app layered over another app disables the lower startup row, because one composition has one command-line owner. +Rows configured by those arguments inject the startup service and read it from their own `!!js` options, with the deployment value beside it as the fallback: + +```yaml +- id: my-app + name: '@example/my-app' + inject: [myAppStartup] + config: + port: !!js ctx.myAppStartup.port ?? 8080 +``` + +On `--help`, the service is not provided, so those rows never activate. An app layered over another app disables the lower startup row, because one composition has one command-line owner. ## Installing from GitHub: the build-script catch diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index c5a15be00b..7b0e0141dc 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -118,9 +118,19 @@ dsh --profile demo inject: [cmdlineArgs] ``` -该行使用应用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/ui/cmdline/README.md) 中的 `runStartup`。启动器把自身 flag 之后的所有参数交给它,因此添加应用专属 flag 无需修改启动器。Loader 只挂载一次组合,等待每一行的注入,再基于其已注入的上下文求值该行的 `!!js` 配置。 +该行使用应用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `runStartup`。启动器把自身 flag 之后的所有参数交给它,因此添加应用专属 flag 无需修改启动器。Loader 只挂载一次组合,等待每一行的注入,再基于其已注入的上下文求值该行的 `!!js` 配置。 -受这些参数配置的行会注入启动服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退。遇到 `--help` 时,该服务不会被提供,所以这些行不会激活。叠加在另一应用之上的应用会禁用下层启动行,因为一套组合只能有一个命令行所有者。 +受这些参数配置的行会注入启动服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退: + +```yaml +- id: my-app + name: '@example/my-app' + inject: [myAppStartup] + config: + port: !!js ctx.myAppStartup.port ?? 8080 +``` + +遇到 `--help` 时,该服务不会被提供,所以这些行不会激活。叠加在另一应用之上的应用会禁用下层启动行,因为一套组合只能有一个命令行所有者。 ## 从 GitHub 安装:构建脚本这道坎 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 5aa765be30..f31426730a 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md -quickstart.md: 6a0b292ce12b32b7993b7de56b35f1df2e7a7153 -quickstart.zh.md: 008245f136e28630c7e8368eeec536e11112a885 +quickstart.md: 8e883efd470fef329a7308f1017cab0b7afcdd67 +quickstart.zh.md: e0e3f4e4754bca588831b6d204f77d1c97a140b3 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 6a0b292ce1..8e883efd47 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -36,10 +36,10 @@ DEEPSEEK_API_KEY=sk-your-key-here Run a non-interactive task and print its final answer: ```sh -pnpm run dsh run "summarize the architecture of this workspace" +pnpm run dsh --profile headless "summarize the architecture of this workspace" ``` -`dsh run` creates and persists a fresh session, prints the final assistant answer, and exits. It starts no Web server or listening port, and a successful run leaves stderr empty. +`dsh --profile headless` creates and persists a fresh session, prints the final assistant answer, and exits. It starts no Web server or listening port, and a successful run leaves stderr empty. ## Step 3: use the Web UI @@ -53,7 +53,7 @@ Open `http://127.0.0.1:3080`. The agent can read and write files, run commands, ## What happened -`dsh run` boots the `headless` profile: [`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) and [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) compose over an empty root, then the runner drives the core Agent and Session services directly. `dsh web` instead composes `dsh-base` with [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml), which owns the Host, HTTP, and browser layers. Both read the same default DeepSeek model route from `dsh-base`. +`dsh --profile headless` boots the `headless` profile: [`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) and [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) compose over an empty root, then the runner drives the core Agent and Session services directly. `dsh web` instead composes `dsh-base` with [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml), which owns the Host, HTTP, and browser layers. Both read the same default DeepSeek model route from `dsh-base`. ## Next steps diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 008245f136..e0e3f4e475 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -36,10 +36,10 @@ DEEPSEEK_API_KEY=sk-your-key-here 运行一个非交互式任务并打印最终回答: ```sh -pnpm run dsh run "summarize the architecture of this workspace" +pnpm run dsh --profile headless "summarize the architecture of this workspace" ``` -`dsh run` 创建并持久化一个新会话,打印最终助手回答,然后退出。它不会启动 Web 服务器或监听端口;成功运行时 stderr 为空。 +`dsh --profile headless` 创建并持久化一个新会话,打印最终助手回答,然后退出。它不会启动 Web 服务器或监听端口;成功运行时 stderr 为空。 ## 第三步:使用 Web UI @@ -53,7 +53,7 @@ pnpm run dsh web ## 运行原理 -`dsh run` 启动 `headless` profile:[`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) 和 [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) 在空根之上组合,随后 runner 直接驱动 core Agent 与 Session 服务。`dsh web` 则由 `dsh-base` 与 [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) 组合,后者拥有 Host、HTTP 与浏览器层。二者都从 `dsh-base` 读取同一个默认 DeepSeek 模型路由。 +`dsh --profile headless` 启动 `headless` profile:[`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) 和 [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) 在空根之上组合,随后 runner 直接驱动 core Agent 与 Session 服务。`dsh web` 则由 `dsh-base` 与 [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) 组合,后者拥有 Host、HTTP 与浏览器层。二者都从 `dsh-base` 读取同一个默认 DeepSeek 模型路由。 ## 下一步 diff --git a/examples/headless-agent/README.i18n.yaml b/examples/headless-agent/README.i18n.yaml index 90dd1c4f2a..0871ba2c15 100644 --- a/examples/headless-agent/README.i18n.yaml +++ b/examples/headless-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/headless-agent/README.md -README.md: f12a56920c79f3a7e257c4e56163f323e4312d11 -README.zh.md: 9e409735f03afc62cd788fa2a5d1afdef0fa6c2a +README.md: 08b36e9c8e558db3b30175b20bd6e0af160ccef5 +README.zh.md: 40d11ebeeb1631af1421b3c013a5e548fd3fd488 diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index f12a56920c..08b36e9c8e 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -10,10 +10,10 @@ This directory owns the replay and real-model test composition for a headless co # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run dsh run "fix the failing test in this workspace" +pnpm run dsh --profile headless "fix the failing test in this workspace" ``` -The product command is [`dsh run`](../../apps/cli/README.md): it accepts one nonblank task, creates and persists a fresh session, prints the final assistant text, and exits. The root `demo:headless` script is only an alias of that command. +The product command is [`dsh --profile headless`](../../apps/cli/README.md): it accepts one nonblank task, creates and persists a fresh session, prints the final assistant text, and exits. The root `demo:headless` script is only an alias of that command. Snapshot suites run this directory's configuration through [`tests/fixtures/headless-driver.ts`](tests/fixtures/headless-driver.ts), an unexported test-only process that emits canonical session events as JSONL before its result record. That stream is test infrastructure, not a supported CLI output format. Child sessions surface only through parent tool events and results. diff --git a/examples/headless-agent/README.zh.md b/examples/headless-agent/README.zh.md index 9e409735f0..40d11ebeeb 100644 --- a/examples/headless-agent/README.zh.md +++ b/examples/headless-agent/README.zh.md @@ -10,10 +10,10 @@ # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run dsh run "fix the failing test in this workspace" +pnpm run dsh --profile headless "fix the failing test in this workspace" ``` -产品命令是 [`dsh run`](../../apps/cli/README.md):它接受一项非空任务,创建并持久化新会话,打印最终 assistant 文本,然后退出。根目录的 `demo:headless` 脚本只是该命令的别名。 +产品命令是 [`dsh --profile headless`](../../apps/cli/README.md):它接受一项非空任务,创建并持久化新会话,打印最终 assistant 文本,然后退出。根目录的 `demo:headless` 脚本只是该命令的别名。 快照套件通过 [`tests/fixtures/headless-driver.ts`](tests/fixtures/headless-driver.ts) 运行本目录的配置。这个未导出且仅供测试使用的进程会在结果记录之前,以 JSONL 发出规范会话事件。该事件流属于测试基础设施,不是受支持的 CLI(命令行界面)输出格式。子会话只通过父会话的工具事件和结果对外显示。 diff --git a/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml b/examples/headless-agent/tests/fixtures/headless-profile.cordis.yml similarity index 100% rename from examples/headless-agent/tests/fixtures/dsh-run.cordis.yml rename to examples/headless-agent/tests/fixtures/headless-profile.cordis.yml diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index b09458ec3d..5fc337527e 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -52,9 +52,9 @@ const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', impor const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.cordis.yml', import.meta.url)) -const dshRunOverlayPath = fileURLToPath(new URL('./fixtures/dsh-run.cordis.yml', import.meta.url)) -const dshRunSessionExpected = join(snapshotsDir, 'dsh-run', 'session.expected.jsonl') -const dshRunFailureExpected = join(snapshotsDir, 'dsh-run', 'stderr.expected.txt') +const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.cordis.yml', import.meta.url)) +const headlessSessionExpected = join(snapshotsDir, 'headless-profile', 'session.expected.jsonl') +const headlessFailureExpected = join(snapshotsDir, 'headless-profile', 'stderr.expected.txt') const cliMockLlmPluginPath = fileURLToPath(new URL('./fixtures/cli-mock-llm.ts', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' @@ -217,14 +217,14 @@ async function prepareCliMockFixture(cwd: string): Promise<void> { } describe('headless stream-json snapshots', () => { - it('runs one task through the product dsh run command', async () => { - const task = 'Prove the product dsh run path with one real tool round trip.' + it('runs one task through the product headless profile command', async () => { + const task = 'Prove the product headless profile path with one real tool round trip.' const result = await runLoaderSmoke({ - label: 'product dsh run snapshot', - tempDirPrefix: 'headless-snapshot-dsh-run-', + label: 'product headless profile snapshot', + tempDirPrefix: 'headless-snapshot-profile-', binScript: dshBinScript, - configPath: dshRunOverlayPath, - binArgs: ['run', '--patch', dshRunOverlayPath, task], + configPath: headlessOverlayPath, + binArgs: ['--profile', 'headless', '--patch', headlessOverlayPath, task], tsconfigPath, env: { DSH_PERMISSION_MODE: 'danger-full-access', @@ -236,11 +236,11 @@ describe('headless stream-json snapshots', () => { const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) expect(logs).toHaveLength(1) const actual = logs[0] - if (actual === undefined) throw new Error('dsh run did not persist its session') + if (actual === undefined) throw new Error('the headless profile did not persist its session') const context = contextFromLogs([actual.content]) const session = scrubRequestHeaders(normalizeSessionLog(actual.content, context)) - if (refreshing) await writeFile(dshRunSessionExpected, session) - expect(session).toBe(await readFile(dshRunSessionExpected, 'utf8')) + if (refreshing) await writeFile(headlessSessionExpected, session) + expect(session).toBe(await readFile(headlessSessionExpected, 'utf8')) expect(session).toContain(task) expect(session).toContain('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP') }, @@ -250,13 +250,13 @@ describe('headless stream-json snapshots', () => { expect(result.stderr).toBe('') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('prints a terminal model failure through the product dsh run command', async () => { + it('prints a terminal model failure through the product headless profile command', async () => { const result = await runLoaderSmoke({ - label: 'product dsh run model failure snapshot', - tempDirPrefix: 'headless-snapshot-dsh-run-failure-', + label: 'product headless profile model failure snapshot', + tempDirPrefix: 'headless-snapshot-profile-failure-', binScript: dshBinScript, - configPath: dshRunOverlayPath, - binArgs: ['run', '--patch', dshRunOverlayPath, 'Trigger the keyless model failure.'], + configPath: headlessOverlayPath, + binArgs: ['--profile', 'headless', '--patch', headlessOverlayPath, 'Trigger the keyless model failure.'], tsconfigPath, expectedExitCode: 1, env: { @@ -268,7 +268,7 @@ describe('headless stream-json snapshots', () => { }) expect(result.stdout).toBe('\n') - await expect(result.stderr).toMatchFileSnapshot(dshRunFailureExpected) + await expect(result.stderr).toMatchFileSnapshot(headlessFailureExpected) }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('prints the original Loader activation error through the assembled one-shot app', async () => { diff --git a/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl b/examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl similarity index 91% rename from examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl rename to examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl index 260ae241b0..65b6f393a6 100644 --- a/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl @@ -2,16 +2,16 @@ {"type":"permission/preset","seq":0,"time":0,"data":{"preset":"danger-full-access"}} {"type":"sandbox/mode","seq":1,"time":0,"data":{"mode":"danger-full-access"}} {"type":"approval/policy","seq":2,"time":0,"data":{"policy":"never"}} -{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} {"type":"turn/start","seq":4,"time":0,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":9,"time":0,"data":{"title":"Prove the product dsh run","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"session/title","seq":9,"time":0,"data":{"title":"Prove the product headless profile","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":11,"time":0,"data":{"provider":"cli-mock","model":"cli-mock"}} -{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product dsh run path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}} +{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product headless profile path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}} {"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"cli-smoke-call","name":"bash","argumentsDelta":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}}} diff --git a/examples/headless-agent/tests/snapshots/dsh-run/stderr.expected.txt b/examples/headless-agent/tests/snapshots/headless-profile/stderr.expected.txt similarity index 100% rename from examples/headless-agent/tests/snapshots/dsh-run/stderr.expected.txt rename to examples/headless-agent/tests/snapshots/headless-profile/stderr.expected.txt diff --git a/package.json b/package.json index 5b07ed60fa..77816c319d 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", - "demo:headless": "node --import tsx/esm apps/cli/src/bin.ts run", + "demo:headless": "node --import tsx/esm apps/cli/src/bin.ts --profile headless", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", diff --git a/packages/boot/README.i18n.yaml b/packages/boot/README.i18n.yaml index 66d3b7b63c..9be0243c92 100644 --- a/packages/boot/README.i18n.yaml +++ b/packages/boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/README.md -README.md: 5e4e483b60adab0b22ddb5279f4cd8fb699b9c35 -README.zh.md: 95a3f98129a7d1fdfaffb3cac6fed77bab7cff56 +README.md: 58a824a7f4af3c62f09b363f7cae041651c536b2 +README.zh.md: 7357b920a067ce74f6f74a69a241d82895675ee4 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index a55e250b6d..d1f9a72df5 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: f3ffdae3846edba6f1a1a4821adade7b6c7fce76 -README.zh.md: 4f31fd743f1ddc57edc9c215a42e79a16afcdecb +README.md: 1d56b2b6d22c08574f8e361955bee1dbe2aca601 +README.zh.md: 5429a1322d0311f03c7c43946753a290e28cd936 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index f3ffdae384..1d56b2b6d2 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -42,7 +42,7 @@ User-level machine-local preferences also live in the Harness home: - **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback. - **`cordis.patch.yml`** (home level) and **`profiles/<name>/cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`. -Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. +Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. ## Model Experience diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 4f31fd743f..5429a1322d 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -42,7 +42,7 @@ profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [` - **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。 - **`cordis.patch.yml`**(home 级)与 **`profiles/<name>/cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。 -长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 +长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 ## 模型体验 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 41274e4a62..94d76dea81 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -215,7 +215,7 @@ export interface UserPatchWatchOptions { * Compose the full patch list for a fresh user-layer generation — * the same composition the app booted with, so a reload can interleave the * new user patches between app-owned layers (bundle layers below, - * overlay/flag patches above). Identity when omitted: the user layer + * overlays above). Identity when omitted: the user layer * is the whole patch list. */ compose?: (userPatches: PatchOptions[]) => PatchOptions[] diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 9207c4b35d..7208acdab6 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/ui/cmdline/README.md -README.md: cd3350678d38802c18ff26dd47214b5019b8c404 -README.zh.md: ad726cd0726cbbd22736321a8c52b04e23d557fa +# pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md +README.md: 5a7e267691cd19548a19e812390865f140e3a620 +README.zh.md: 6d5892e97708a6bc464dafee3dcba2521129ea93 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index cd3350678d..5a7e267691 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -60,7 +60,7 @@ Loader defers a row's `!!js` interpolation until that row's declared injections ### One command line, one owner -A composition has exactly one command-line owner. An app that layers over another one disables the underlying startup row and names both services, so the rows it absorbed start on the values their own fallbacks name — [`dsh-headless`](../../bundle/headless/README.md) does this over [`dsh-web-app`](../../bundle/web-app/README.md). +A composition has exactly one command-line owner. An app that layers over another one disables the underlying startup row and provides every startup service its retained rows inject. An out-of-tree plugin brings its own commander copy, so commander's control-flow errors are detected structurally rather than by class identity; an identity check would rethrow a printed help as a fatal load failure. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index ad726cd072..6d5892e977 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -60,7 +60,7 @@ Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活 ### 一条命令行,一个所有者 -一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的启动行,并同时点名两个服务,使它吸收过来的行按各自回退值启动:[`dsh-headless`](../../bundle/headless/README.md) 相对 [`dsh-web-app`](../../bundle/web-app/README.md) 就是这么做的。 +一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的启动行,并提供保留下来的各行所注入的全部启动服务。 树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。 diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 7d1a93f71d..90af8c212f 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-cmdline", - "description": "Command-line seam between a dsh launcher and app bundles: cmdlineArgs exposes inner arguments, while injected startup rows parse them into app-owned runtime services", + "description": "Command-line handoff between a dsh launcher and app bundles: cmdlineArgs exposes inner arguments, while injected startup rows parse them into app-owned runtime services", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index f927a21072..beb2f6a4c3 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -140,10 +140,9 @@ export type StartupPlan<T = unknown> = (program: Command, rows: readonly EntryOp * is written, the service is never provided, dependent rows stay pending, and * `ctx.appExit` is requested. * - * An app that layers over another one (the one-shot bundle rides over the web - * bundle) disables the underlying startup row and names both services, because - * a composition has exactly one command-line owner: the rows of the app it - * absorbed then start on the values their own fallbacks name. + * A custom app that layers over another one disables the underlying startup + * row and names every startup service its retained rows inject, because a + * composition has exactly one command-line owner. * @param ctx - plugin context carrying `cmdlineArgs`, `appExit`, and the Loader. * @param services - the service name, or names, this startup row provides. * @param program - the app's commander program, with its flags and description already declared. @@ -186,8 +185,8 @@ export function runStartup<T>( } catch (error) { // exitOverride turns help, version, a parse error, and a plan's own // program.error() into a CommanderError; commander has already written the - // text through the output configured above. The app's rows ship disabled, - // so leaving them alone is what keeps the app unstarted. + // text through the output configured above. With no startup service, + // dependent rows remain pending and the app stays unstarted. if (!isCommanderError(error)) throw error exit(error.exitCode) return undefined diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 4b1403ca59..92dc62d7ff 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -25,7 +25,7 @@ export const name = 'headless-runner' /** Core services required before the one-shot turn can start. */ export const inject = ['agentDefaultModel', 'agents', 'sessions'] -/** Plugin config: the task, patched in by the launcher. */ +/** Plugin config: the task resolved from this app's injected startup service. */ export interface Config { /** The prompt text for the single run. */ task: string diff --git a/packages/bundle/headless/tsconfig.json b/packages/bundle/headless/tsconfig.json index 17d11ed3ff..8e0b4ae4b3 100644 --- a/packages/bundle/headless/tsconfig.json +++ b/packages/bundle/headless/tsconfig.json @@ -33,10 +33,7 @@ "path": "../../support/invariants" }, { - "path": "../../ui/cmdline" - }, - { - "path": "../web-app" + "path": "../../boot/cmdline" } ] } diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index e702feca98..b1d297ff75 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: 1b54e6d29ad49c62b7862bf7fffcd6d24831c643 -README.zh.md: 82b7c4c2574aa93697e8483c362cf4ec75630f34 +README.md: fb6a1a3ee5293c7e90afae11a76fe5a8598f3ee8 +README.zh.md: d8276514d94e658788371034795a073abefbf6ac diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 1b54e6d29a..fb6a1a3ee5 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,19 +2,19 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, `--workspace-root`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model Experience -### Web-surface prompt section and bash runtime variables +### Harness-source and Web-surface context #### What the model sees -When `surfaceContext` is true, the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither the section nor the variables are registered. +When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither section nor the variables are registered. #### Token effect -One prompt paragraph per session plus two managed-environment variable lines; constant per process. +One source line and one prompt paragraph per session plus two managed-environment variable lines; constant per process. #### KV Cache effect diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 82b7c4c257..d8276514d9 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,19 +2,19 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev`、`--workspace-root` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型体验 -### Web 表层提示词段落与 bash 运行时变量 +### Harness 源码与 Web 表层上下文 #### 模型看到的内容 -当 `surfaceContext` 为 true 时,全局段落 `app:web-surface`(顺序 −98)向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,该提示词段和这些变量都不会注册。 +当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和这些变量都不会注册。 #### Token 影响 -每个会话一段提示词,外加两行受管环境变量;每个进程内保持恒定。 +每个会话一行源码说明和一段提示词,外加两行受管环境变量;每个进程内保持恒定。 #### KV Cache 影响 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 656a3374cb..922600387e 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -80,9 +80,6 @@ # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' - inject: [webStartup] - config: - workspaceRoot: !!js ctx.get('webStartup')?.workspaceRoot # This app's command-line startup row. It owns the web flag family and its # --help, and provides webStartup to the rows that inject it. @@ -92,9 +89,9 @@ # ── layer 2: transport/service ────────────────────────────────────────────── - # Plain route-registration carrier; host and port arrive as `dsh web` - # flag patches over these defaults. The dist is served by the web-runtime - # row below through the fallback seat. + # Plain route-registration carrier; host and port come from the app's + # startup service, with these deployment fallbacks. The dist is served by + # the web-runtime row below through the fallback seat. - id: webserver name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] @@ -119,7 +116,7 @@ lanAddresses: !!js ctx.get('webStartup')?.lanAddresses ?? [] # The client-plugin reload chain: a dev-only row this bundle ships off, - # which the entrypoint turns on for `--dev`. It is a row rather than a + # which the runtime row turns on for `--dev`. It is a row rather than a # child of web-runtime because its node half is a client-side package, # which a host-side bundle cannot import. - id: client-hmr diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index e8240e1b63..b6740c7b77 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -38,8 +38,8 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 27b9a4e27a..5385fd1d66 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -41,12 +41,12 @@ export type WebMode = 'production' | 'development' export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode - /** Print the URL line on activation; a headless layer over this bundle turns it off. */ + /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** * Register the model-visible surface context (the `app:web-surface` prompt * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot - * layer turns it off: its user is not interacting through the GUI, so the + * non-interactive layer can turn it off when its user is not in the GUI, so the * orientation text would be false. */ surfaceContext: boolean diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index e636366f3c..a276e7e032 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -1,10 +1,10 @@ /** * The web app's startup row: it owns the `dsh --profile web` flag family - * (`--host`, `--port`, `--dev`, `--workspace-root`, `--trusted-host`) and its - * `--help` text, turns those flags into changes on the rows that inject - * {@link WEB_STARTUP_SERVICE}, and then provides it. Until it does, no web row - * starts, so `dsh --profile web --help` prints this command's help and the - * server never binds. + * (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help` text, + * turns those flags into changes on the rows that inject + * {@link WEB_STARTUP_SERVICE}, and then provides it. Until it does, no + * flag-configured web row starts, so `dsh --profile web --help` prints this + * command's help and the server never binds. * @module @deepseek-ai/dsh-web-app/startup */ @@ -33,8 +33,6 @@ export interface WebStartupValues { host?: string /** `--port`, absent when the invocation did not name one. */ port?: number - /** `--workspace-root`, absent when the invocation did not name one. */ - workspaceRoot?: string /** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */ mode: 'production' | 'development' /** @@ -101,7 +99,6 @@ interface WebOptions { host?: string port?: string dev?: boolean - workspaceRoot?: string trustedHost?: string[] } @@ -117,14 +114,13 @@ function webCommand(): Command { .option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine') .option('--port <port>', 'listen port; pass 0 to let the OS pick a free one') .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') - .option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI') .option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .addHelpText('after', ` Examples: - dsh web serve on the composed host and port - dsh web --port 8080 serve on another port - dsh web --host 0.0.0.0 reach it from another machine on the LAN - dsh web --dev mount the client-plugin HMR receiver + dsh --profile web serve on the composed host and port + dsh --profile web --port 8080 serve on another port + dsh --profile web --host 0.0.0.0 reach it from another machine on the LAN + dsh --profile web --dev mount the client-plugin HMR receiver `) } @@ -146,7 +142,6 @@ function planWebStartup(program: Command, rows: readonly EntryOptions[], ctx: Co return found } const webserver = row('webserver') - row('api-gateway') row('web-runtime') const connection = row('connection') // Include preserves nested row expressions until their own injections are @@ -162,7 +157,6 @@ function planWebStartup(program: Command, rows: readonly EntryOptions[], ctx: Co return { ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, - ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, // mode and lanAddresses describe this invocation, never the deployment, so // they are resolved on every boot. mode: options.dev === true ? 'development' : 'production', diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index 5a7c80dcc4..f58c771aaa 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -80,10 +80,6 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) ' config:', ` trustedHosts: !!js ctx.get('${WEB_STARTUP_SERVICE}')?.trustedHosts ?? ${JSON.stringify(trustedHosts)}`, ], - '- id: api-gateway', - ` name: ${rowUrl}`, - ` inject: [${WEB_STARTUP_SERVICE}]`, - ' disabled: true', // A second reader keeps the composition honest when the webserver row is // the one under test: the service must still have someone to serve. '- id: web-runtime', @@ -118,10 +114,9 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) describe('web startup', () => { it('resolves each flag into the value its row reads', async () => { - const { values } = await bootStartup(['--port', '8080', '--workspace-root', '/w']) + const { values } = await bootStartup(['--port', '8080']) expect(values).toEqual({ port: 8080, - workspaceRoot: '/w', mode: 'production', trustedHosts: [], lanAddresses: [], diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index b15ebb1664..195aa985e8 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -18,7 +18,10 @@ "path": "../../../vendor/loader" }, { - "path": "../../ui/cmdline" + "path": "../../boot/app-boot" + }, + { + "path": "../../boot/cmdline" }, { "path": "../../host/frontend-static" diff --git a/packages/core/agent-default-model/README.i18n.yaml b/packages/core/agent-default-model/README.i18n.yaml index 7835a159bc..c84c0ea271 100644 --- a/packages/core/agent-default-model/README.i18n.yaml +++ b/packages/core/agent-default-model/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-default-model/README.md -README.md: 98bc7d082e62a764868f8acd323c4617e9839e61 -README.zh.md: 807b612bd25e49aa318c13c8c8dc7595a6459080 +README.md: e86be7c37a1f994ca52f018144ef6a2409bd1eea +README.zh.md: 00250c28ef8c03d4b33fe1c1bfca138a022f6638 diff --git a/packages/core/agent-default-model/README.md b/packages/core/agent-default-model/README.md index 98bc7d082e..e86be7c37a 100644 --- a/packages/core/agent-default-model/README.md +++ b/packages/core/agent-default-model/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh run` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults. +The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh --profile headless` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults. The plugin config requires `{ provider, model }`. That composition entry is the base of the `agent-default-model` Settings section; a mounted settings provider layers the user's choice over it and changes are visible on the next `currentSelection()` read. `reasoningEffort` belongs to the Settings section but deliberately not to plugin config: a complete saved selection can clear an effort when the next selected model has none, while a composition value would be inherited again. diff --git a/packages/core/agent-default-model/README.zh.md b/packages/core/agent-default-model/README.zh.md index 807b612bd2..00250c28ef 100644 --- a/packages/core/agent-default-model/README.zh.md +++ b/packages/core/agent-default-model/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -该部署默认值供入口在创建尚无会话级模型选择的 Agent 时使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`;`dsh run` 这类直接入口与 ApiProxy 这类由 Host 支撑的入口读取同一服务,而不是分别持有平行的提供方/模型默认值。 +该部署默认值供入口在创建尚无会话级模型选择的 Agent 时使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`;`dsh --profile headless` 这类直接入口与 ApiProxy 这类由 Host 支撑的入口读取同一服务,而不是分别持有平行的提供方/模型默认值。 插件配置必须提供 `{ provider, model }`。该组合配置项构成 Settings 中 `agent-default-model` 分节的基础层;挂载的设置提供方在其上叠加用户选择,更改会在下一次调用 `currentSelection()` 时可见。`reasoningEffort` 属于该 Settings 分节,但特意不属于插件配置:完整保存的选择必须能在下一个选定模型没有推理(reasoning)强度时清除旧值,而组合配置值会再次被继承。 diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index 2270947eea..7cd26439c2 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/README.md -README.md: d8369b1e263e72c7b0ac1687c3b14a5d723ab944 -README.zh.md: acb402e925f692beaacbe0ab4e029691d664dbe8 +README.md: 0048d14ec49776f036d841bbc0579a6867e513bb +README.zh.md: 1b7acc5646071f4fc21e9238e4e440c192a1e82a diff --git a/packages/examples/README.md b/packages/examples/README.md index d8369b1e26..0048d14ec4 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -10,7 +10,7 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP automation application bundle | | [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | External-config JSON-RPC runtime | -`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation entry point, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it. +`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation entry point, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh --profile headless`; no package in this directory provides it. These packages are not product API. Product seams and entry points remain in their owning groups; demo bundles select concrete compositions. diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index acb402e925..1b7acc5646 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -10,7 +10,7 @@ | [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP(Agent Client Protocol)自动化应用组合包 | | [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | 外部配置 JSON-RPC 运行时 | -`agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行由 `dsh run` 提供;本目录没有任何包提供该功能。 +`agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行由 `dsh --profile headless` 提供;本目录没有任何包提供该功能。 这些包不是产品 API。产品 seam 与产品入口仍位于各自的归属组;演示组合包选择具体组合。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index e4ee0922d6..8bf63adb20 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 03726c6671ec711704870d23722d83c72d9c4d35 -README.zh.md: f001866af2015671ed6429b392e3f880000e6c38 +README.md: d59f3f5ddb9929356e23a467ce5840f1673663f7 +README.zh.md: 737361f2bd85c0ea02b9d29734f58c34bc324969 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 03726c6671..d59f3f5ddb 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -56,7 +56,7 @@ The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-pag ## Carrier layer (`/client` + root) -`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core entry point and does not mount this package. +`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh --profile headless` is a direct core entry point and does not mount this package. ## Model Experience diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index f001866af2..737361f2bd 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -56,7 +56,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 载体层(`/client` + 根路径) -`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh run` 是直连 core 的入口,不挂载本包。 +`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。 ## 模型体验 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1dc47e50d8..99801b0001 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:^ version: link:../../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../packages/util/environment '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../packages/goal/goal @@ -1492,6 +1495,9 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../boot/app-boot '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 7ee9320cea..3de7e389a8 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -111,6 +111,9 @@ export const SERVICE_PAGE: Record<string, string> = { */ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = { agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle', + appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract', + appReady: 'not a service: launcher-provided whole-composition readiness promise — packages/boot/cmdline/README.md owns the launcher contract', + cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract', configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract', launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract', dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract', diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 63050b2079..f033ece889 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/<name>`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow <a href=\"https://x.com/Deepseekharness\">DeepSeek Harness on Twitter</a> for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/<name>`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow <a href=\"https://x.com/Deepseekharness\">DeepSeek Harness on Twitter</a> for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/<name>` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 <a href=\"https://wj.qq.com/s2/27234598/03eb/\">DeepSeek Harness 微信社区申请页面</a> 申请加入。\n\n<p>\n <img src=\"assets/community-wecom-survey.png\" alt=\"DeepSeek Harness 微信社区二维码\" width=\"240\">\n</p>\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/<name>` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 <a href=\"https://wj.qq.com/s2/27234598/03eb/\">DeepSeek Harness 微信社区申请页面</a> 申请加入。\n\n<p>\n <img src=\"assets/community-wecom-survey.png\" alt=\"DeepSeek Harness 微信社区二维码\" width=\"240\">\n</p>\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", diff --git a/tsconfig.base.json b/tsconfig.base.json index b5a7e9b1e4..fcdea094f3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -131,6 +131,8 @@ // group prefix with a dedicated wildcard per group instead. "@deepseek-ai/dsh-host-*/invariant": ["./packages/host/*/src/invariant.ts"], "@deepseek-ai/dsh-client-*/invariant": ["./packages/client/*/src/invariant.ts"], + "@deepseek-ai/dsh-headless/startup": ["./packages/bundle/headless/src/startup.ts"], + "@deepseek-ai/dsh-web-app/startup": ["./packages/bundle/web-app/src/startup.ts"], "@deepseek-ai/dsh-client-*/client": ["./packages/client/*/src/client"], // One wildcard maps every @deepseek-ai/dsh-<name> to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is diff --git a/tsdown.config.ts b/tsdown.config.ts index 2042e81db3..6dd12f3eb4 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -17,7 +17,7 @@ export default defineConfig(({ env }) => { const client = isBuildFaceClient(env?.DSH_BUILD_FACE) return { workspace: ['vendor/*', 'packages/*/*', 'apps/cli'], - entry: client ? '' : ['lib/types/{index,invariant}.js'], + entry: client ? '' : ['lib/types/{index,invariant,startup}.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 0f35cdfa97..4eef5505e1 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -8,7 +8,12 @@ import { evaluate } from './utils.ts' /** Static plugin hook for resolving a container config while preserving nested entry configs. */ export const EntryConfigResolver = Symbol.for('cordis.loader.entry-config-resolver') -/** Resolver installed at {@link EntryConfigResolver}. */ +/** + * Resolve a container's own config while preserving any nested entry configs. + * @param ctx - the container plugin context. + * @param config - the container's raw config. + * @returns the config to validate for this activation. + */ export type EntryConfigResolver = (ctx: Context, config: any) => any /** Serialized plugin entry options stored in loader config files. */ From 37ee7b0f24ac0fd0def22be76dcd96ad6b1606a2 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Sun, 9 Aug 2026 18:42:54 +0800 Subject: [PATCH 162/229] fix(cmdline): reject multiple command-line owners --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 2 +- .../2026-08-06-app-owned-command-line.zh.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 2 +- packages/boot/cmdline/README.zh.md | 2 +- packages/boot/cmdline/src/index.ts | 21 +++++++++- packages/boot/cmdline/tests/cmdline.spec.ts | 38 ++++++++++++++++++- 11 files changed, 68 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 97b4a529f3..995447c42e 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 948de243abe39c7b4af014f8709e102a53aa9797 -2026-08-06-app-owned-command-line.zh.md: 00cce42d123c788f78386a718f7711cad0e0c234 +2026-08-06-app-owned-command-line.md: 21433d96d1dbcb26f4104fffb5a78b389d78bca8 +2026-08-06-app-owned-command-line.zh.md: 7b123f89c8f844ae396df09136d69215f5ad8d26 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 948de243ab..21433d96d1 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -12,7 +12,7 @@ After profiles, compositions were installable but their command lines were not. The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. -The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`), `ctx.appExit`, and `ctx.appReady`. An app consumes them from its **startup row**. Both the Loader row and plugin inject `cmdlineArgs`; the plugin calls `runStartup(ctx, service, program, plan)` with its own commander program and provides what it resolved as its own service. The Loader-row injection is also the launcher's discovery declaration; there is no parallel bundle-manifest field. The rows the app configures inject that service and read it from their own config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`), `ctx.appExit`, and `ctx.appReady`. An app consumes them from its **startup row**. Both the Loader row and plugin inject `cmdlineArgs`; the plugin calls `runStartup(ctx, service, program, plan)` with its own commander program and provides what it resolved as its own service. The Loader-row injection is also the launcher's discovery declaration; there is no parallel bundle-manifest field. Before boot, the launcher rejects nonempty app arguments with no active declaration and any composition with multiple active declarations. The rows the app configures inject that service and read it from their own config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` provides no startup service, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index 00cce42d12..7b123f89c8 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -12,7 +12,7 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 -新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)、`ctx.appExit` 和 `ctx.appReady`。应用从自己的**启动行**消费它们。Loader 行与插件都注入 `cmdlineArgs`;插件以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。Loader 行的注入同时也是启动器的发现声明,不再需要一份平行的组合包 manifest 字段。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)、`ctx.appExit` 和 `ctx.appReady`。应用从自己的**启动行**消费它们。Loader 行与插件都注入 `cmdlineArgs`;插件以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。Loader 行的注入同时也是启动器的发现声明,不再需要一份平行的组合包 manifest 字段。启动器会在 boot 前拒绝没有活跃声明却带有非空应用参数的调用,也会拒绝存在多个活跃声明的组合。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 不提供启动服务,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index b6c1ea5ab9..bce5b999d5 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: b4a8dfe8a0473e69a0c82e33aba2d1f4210a2477 -README.zh.md: 287a215b6abb31c7f0375987210eb9703acf5657 +README.md: f28d77ccba7380426df2dd1769e33be0f7256d27 +README.zh.md: 3d8fbae31780c00f05384a1e4010fda2b6ce3246 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index b4a8dfe8a0..f28d77ccba 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -18,7 +18,7 @@ The launcher's flags come first and end at the first token it does not recognize A composition mounts once. A Loader row that injects `cmdlineArgs` parses this app's arguments and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the startup service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port. -Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. A profile with no active row injecting `cmdlineArgs` accepts no app arguments; it rejects them before mounting any row instead of silently ignoring them. +Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. A profile with no active row injecting `cmdlineArgs` accepts no app arguments; it rejects them before mounting any row instead of silently ignoring them. A composition with multiple active rows injecting `cmdlineArgs` is always rejected because two parsers cannot own the same command line. The shipped apps own these command lines: diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 287a215b6a..3d8fbae317 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -18,7 +18,7 @@ 一套组合只挂载一次。注入 `cmdlineArgs` 的 Loader 行解析本应用的参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖启动服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。 -启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。若 profile 中没有注入 `cmdlineArgs` 的活跃行,该 profile 不接受应用参数;启动器会在挂载任何行之前拒绝这些参数,而不是静默忽略。 +启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。若 profile 中没有注入 `cmdlineArgs` 的活跃行,该 profile 不接受应用参数;启动器会在挂载任何行之前拒绝这些参数,而不是静默忽略。若组合中有多个注入 `cmdlineArgs` 的活跃行,启动器总会拒绝该组合,因为两个解析器不能共同持有同一条命令行。 随附的各应用持有这些命令行: diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 7208acdab6..7a7e72c3ce 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md -README.md: 5a7e267691cd19548a19e812390865f140e3a620 -README.zh.md: 6d5892e97708a6bc464dafee3dcba2521129ea93 +README.md: dc267080d32d492e132df4592ddf742454a95ad2 +README.zh.md: e183156ab4a7907f8ae1e3259b2e09d458cec47d diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 5a7e267691..dc267080d3 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -35,7 +35,7 @@ The Loader-row injection is also its discovery declaration, so no bundle manifes inject: [cmdlineArgs] ``` -The launcher uses that injection only to reject arguments for a composition with no command-line owner. Loader mounts the composition once and holds each row until its own injections are active. +The launcher uses that injection only to reject arguments for a composition with no command-line owner, and to reject a composition with multiple owners. Loader mounts the composition once and holds each row until its own injections are active. Every row the app configures from flags then reads what the startup row resolved, naming the key it takes and the value it falls back to: diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index 6d5892e977..e183156ab4 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -35,7 +35,7 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字 inject: [cmdlineArgs] ``` -启动器只用该注入来拒绝那些没有命令行所有者却带有应用参数的组合。Loader 只挂载一次整套组合,并让每一行等待自身的注入激活。 +启动器只用该注入来拒绝那些没有命令行所有者却带有应用参数的组合,以及拒绝存在多个所有者的组合。Loader 只挂载一次整套组合,并让每一行等待自身的注入激活。 应用用 flag 配置的每一行随后读取启动行解析出的取值,各自点名自己取用的键,以及回退时使用的值: diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index beb2f6a4c3..32a2572d2b 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -98,9 +98,28 @@ export function provideCmdline(ctx: Context, host: CmdlineHost): void { * adding the same injection its startup plugin already requires. * @param rows - the composed Loader rows. * @returns whether this composition has a command-line owner. + * @throws when more than one active row claims the command line. */ export function hasCmdlineConsumer(rows: readonly EntryOptions[]): boolean { - return rows.some(row => row.disabled !== true && waitsForAny(row.inject, ['cmdlineArgs'])) + const consumers: string[] = [] + const visit = (entries: readonly EntryOptions[], ancestorDisabled = false, prefix = ''): void => { + for (const row of entries) { + const id = prefix + row.id + // Loader group containers stay active when disabled, but their children + // inherit that disabled state. + const active = row.group === true || (!ancestorDisabled && row.disabled !== true) + if (active && waitsForAny(row.inject, ['cmdlineArgs'])) consumers.push(id) + if (row.group === true && Array.isArray(row.config)) { + visit(row.config, ancestorDisabled || row.disabled === true, `${id}:`) + } + } + } + visit(rows) + if (consumers.length > 1) { + const ids = consumers.map(id => JSON.stringify(id)).join(', ') + throw new Error(`dsh-cmdline: multiple active rows inject cmdlineArgs (${ids}); disable all but one startup row`) + } + return consumers.length === 1 } /** The process streams commander output is written to; production writes to the process. */ diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index d724fe9531..c6e8888357 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -130,6 +130,40 @@ describe('hasCmdlineConsumer', () => { { id: 'ordinary', name: 'ordinary' }, { id: 'disabled-startup', name: 'disabled-startup', inject: ['cmdlineArgs'], disabled: true }, ])).toBe(false) + expect(() => hasCmdlineConsumer([ + { id: 'web-startup', name: 'web-startup', inject: ['cmdlineArgs'] }, + { id: 'tui-startup', name: 'tui-startup', inject: ['cmdlineArgs'] }, + ])).toThrow('multiple active rows inject cmdlineArgs ("web-startup", "tui-startup")') + }) + + it('walks nested groups and ignores consumers disabled by an ancestor', () => { + expect(hasCmdlineConsumer([{ + id: 'app', + name: 'cordis:group', + group: true, + config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], + }])).toBe(true) + expect(hasCmdlineConsumer([{ + id: 'app', + name: 'cordis:group', + group: true, + disabled: true, + config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], + }])).toBe(false) + expect(() => hasCmdlineConsumer([ + { + id: 'first', + name: 'cordis:group', + group: true, + config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], + }, + { + id: 'second', + name: 'cordis:group', + group: true, + config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], + }, + ])).toThrow('multiple active rows inject cmdlineArgs ("first:startup", "second:startup")') }) }) @@ -187,9 +221,9 @@ describe('runStartup', () => { .toThrow('absentStartup: no row injects this startup service') }) - it('provides an empty value when the app declares no plan', async () => { + it('accepts a service-name list when the app declares no plan', async () => { const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) - runStartup(ctx, 'demoStartup', demoCommand()) + runStartup(ctx, ['demoStartup'], demoCommand()) expect(ctx.get('demoStartup')).toEqual({}) }) }) From a4d8c0da9b63d14c047705d711ff717d1b47d6d4 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Mon, 10 Aug 2026 19:58:40 +0800 Subject: [PATCH 163/229] fix(web): include the HMR receiver in the initial client graph --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 2 +- .../2026-08-06-app-owned-command-line.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 2 +- packages/boot/cmdline/README.zh.md | 2 +- packages/boot/cmdline/src/index.ts | 4 +- packages/boot/cmdline/tests/cmdline.spec.ts | 52 +++++++++++++++++-- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 14 ++--- packages/bundle/web-app/src/index.ts | 17 +++--- packages/bundle/web-app/tests/web-app.spec.ts | 31 ++++++----- vendor/README.md | 1 + vendor/loader/src/config/entry.ts | 22 +++++++- 19 files changed, 125 insertions(+), 48 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 995447c42e..f59ff0b1a8 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 21433d96d1dbcb26f4104fffb5a78b389d78bca8 -2026-08-06-app-owned-command-line.zh.md: 7b123f89c8f844ae396df09136d69215f5ad8d26 +2026-08-06-app-owned-command-line.md: 8556c2bbe27189a0784edf4b2a376c932807e020 +2026-08-06-app-owned-command-line.zh.md: f5a7be3500f239e03e0f05d724fa53ffaf28e624 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 21433d96d1..8556c2bbe2 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -27,7 +27,7 @@ Four framework facts shape the mechanism: - **A profile's rows arrive inside the root include's `patches` option.** Include is an entry-tree owner, so its static entry-config resolver interpolates Include's own options while preserving nested `!!js` nodes for their target rows instead of recursively evaluating them in the Include context. - **Cordis activates a fiber only after all declared injections are active.** Immediately before each activation, Cordis runs the `internal/config` waterfall against the fiber's own context; Loader's listener interpolates the raw config after Cordis snapshots its injected services. - **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the waterfall, HMR carries the raw config to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services. -- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and an active row enables it (`dsh web --dev` and its reload chain); the enabled row then follows ordinary injection ordering. +- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and an active row enables it (`dsh web --dev` and its reload chain). Enablement is an in-memory Loader override rather than an options rewrite, so Include reapplication cannot silently disable it. The Web bundle also starts client discovery only after enabling the optional row, ensuring the first browser graph already contains its HMR receiver. This leaves dependency ordering in Cordis activation and Loader interpolation, which own it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index 7b123f89c8..f5a7be3500 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -27,7 +27,7 @@ boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Lo - **profile 的各行位于根 include 的 `patches` 选项内部。** Include 是条目树所有者,因此它的静态条目配置解析器会插值 Include 自身的选项,同时为目标行保留嵌套的 `!!js` 节点,而不是在 Include 上下文中递归求值。 - **Cordis 只在所有声明的注入都已激活后才激活 fiber。** 每次激活前一刻,Cordis 会基于 fiber 自身上下文运行 `internal/config` waterfall;Cordis 快照注入服务之后,Loader 的监听器再插值原始配置。 - **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfall,HMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。 -- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路);启用后的行继续遵循普通注入顺序。 +- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路)。启用采用 Loader 的内存覆盖而非改写选项,因此 Include 重新应用配置时不会悄然将其禁用。Web 组合包还会在启用可选行之后才启动客户端发现,确保首份浏览器图中已经包含 HMR 接收端。 这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index e4f119da06..3761963233 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: 836a7f6a81f8c77be12fd10be5b8204be8c1acc0 -config-catalog.zh.md: 22dca1252d93ea9ce223464079f3c51c35eeb89d +config-catalog.md: 64e65e93b165ede2ac6c8fa399b9ce461938b939 +config-catalog.zh.md: 9f4a7ab071d68cfaf8ae3ea42458babfee67c9fd diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 836a7f6a81..64e65e93b1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2546,7 +2546,7 @@ export interface Config { export type WebMode = 'production' | 'development' ``` -Source: [`packages/bundle/web-app/src/index.ts:41`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:40`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 22dca1252d..9f4a7ab071 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2547,7 +2547,7 @@ export interface Config { export type WebMode = 'production' | 'development' ``` -来源:[`packages/bundle/web-app/src/index.ts:41`](../packages/bundle/web-app/src/index.ts) +来源:[`packages/bundle/web-app/src/index.ts:40`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 7a7e72c3ce..db3d559d0a 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md -README.md: dc267080d32d492e132df4592ddf742454a95ad2 -README.zh.md: e183156ab4a7907f8ae1e3259b2e09d458cec47d +README.md: 571ea7acf9f7be1ee2bdadafae2fc71b99d4536a +README.zh.md: 271acd6be4d58bf12d41bc02dd3ccabc7359a269 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index dc267080d3..571ea7acf9 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -56,7 +56,7 @@ Every row the app configures from flags then reads what the startup row resolved Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch flag cannot be silently reset. -`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). Loader applies the enabled row's ordinary injection ordering. +`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). The activation is an in-memory override: it does not rewrite the row's configured `disabled` value and survives config reapplication for that mounted entry. Loader applies the enabled row's ordinary injection ordering. ### One command line, one owner diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index e183156ab4..271acd6be4 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -56,7 +56,7 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字 Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`:Loader 索取 `webserver` 的配置之前,Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。 -`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。Loader 会对启用后的行应用普通的注入顺序。 +`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。该激活是内存中的覆盖:它不会改写行所配置的 `disabled` 值,并会在已挂载条目的配置重新应用后继续生效。Loader 会对启用后的行应用普通的注入顺序。 ### 一条命令行,一个所有者 diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index 32a2572d2b..1e2c9e3d0b 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -221,6 +221,8 @@ export function runStartup<T>( * A row cannot be inserted from inside a mounting plugin — the Loader returns a * prefixed id it then fails to resolve — so a conditional row ships disabled * and a row mounted beside it enables it after startup resolves the invocation. + * The Loader keeps that activation in memory, separate from serialized options, + * so reapplying the composition cannot restore the invocation's row to disabled. * @param ctx - plugin context whose Loader tree carries the row. * @param id - the row id. * @returns nothing once the row has started or is waiting for its dependencies. @@ -231,7 +233,7 @@ export async function enableRow(ctx: Context, id: string): Promise<void> { if (loader === undefined) throw new Error('dsh-cmdline: enabling a row requires the Loader service') const entry = [...loader.entries()].find(candidate => candidate.options.id === id) if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`) - await entry.update({ disabled: false }) + await entry.enableRuntime() } /** diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index c6e8888357..9c046d4b94 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -234,17 +234,63 @@ describe('enableRow', () => { await expect(enableRow(withoutLoader, 'client-hmr')).rejects.toThrow('requires the Loader service') const ctx = new Context() - let update: unknown + let enabled = false ctx.provide('loader', { entries: () => [{ options: { id: 'client-hmr' }, - update: async (options: unknown) => { update = options }, + enableRuntime: async () => { enabled = true }, }], } as never) await enableRow(ctx, 'client-hmr') - expect(update).toEqual({ disabled: false }) + expect(enabled).toBe(true) await expect(enableRow(ctx, 'absent')).rejects.toThrow('no "absent" row to enable') }) + + it('keeps invocation-only activation through config reapplication', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-runtime-enable-')) + const observed = { starts: 0, stops: 0 } + ;(globalThis as unknown as { __runtimeEnableObserved: typeof observed }).__runtimeEnableObserved = observed + writeFileSync(join(dir, 'conditional.mjs'), ` +export function apply(ctx) { + globalThis.__runtimeEnableObserved.starts += 1 + ctx.effect(() => () => { globalThis.__runtimeEnableObserved.stops += 1 }) +} +`) + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: conditional', + ` name: ${pathToFileURL(join(dir, 'conditional.mjs')).href}`, + ' disabled: true', + '', + ].join('\n')) + + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(join(dir, 'cordis.yml')).href }, + }) + await ctx.loader.await() + const conditional = [...ctx.loader.entries()].find(entry => entry.options.id === 'conditional') + const include = [...ctx.loader.entries()].find(entry => entry.options.name === 'cordis:include') + expect(conditional).toBeDefined() + expect(include?.fiber).toBeDefined() + expect(conditional?.options.disabled).toBe(true) + expect(observed).toEqual({ starts: 0, stops: 0 }) + + await enableRow(ctx, 'conditional') + await ctx.loader.await() + expect(conditional?.disabled).toBe(false) + expect(conditional?.options.disabled).toBe(true) + expect(observed).toEqual({ starts: 1, stops: 0 }) + + await include!.fiber!.update(include!.options.config, true) + await ctx.loader.await() + expect(conditional?.disabled).toBe(false) + expect(conditional?.options.disabled).toBe(true) + expect(observed).toEqual({ starts: 1, stops: 0 }) + disposers.push(async () => { await ctx.fiber.dispose() }) + }) }) describe('provideCmdline', () => { diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index b1d297ff75..6053356414 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: fb6a1a3ee5293c7e90afae11a76fe5a8598f3ee8 -README.zh.md: d8276514d94e658788371034795a073abefbf6ac +README.md: 47b582225e768ac035d12947939c7a7eb700458c +README.zh.md: 61e134f90e7ae57cb6220e92880c001f0d06bae2 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index fb6a1a3ee5..47b582225e 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model Experience diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index d8276514d9..61e134f90e 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型体验 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 922600387e..37b19e7645 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -116,8 +116,8 @@ lanAddresses: !!js ctx.get('webStartup')?.lanAddresses ?? [] # The client-plugin reload chain: a dev-only row this bundle ships off, - # which the runtime row turns on for `--dev`. It is a row rather than a - # child of web-runtime because its node half is a client-side package, + # which the runtime row turns on before client discovery. It is a row rather + # than a child of web-runtime because its node half is a client-side package, # which a host-side bundle cannot import. - id: client-hmr name: '@deepseek-ai/dsh-client-hmr' @@ -126,12 +126,14 @@ # ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── - # Dual-face: node half scans this very tree for dsh.client rows, composes - # window.__DSH_BOOT__, serves /plugins/<id>/client.js; browser half is the - # module table the shell kernel constructs before cordis exists (adopted - # as a plugin entry by the kernel, never fetched). + # Dual-face: this waits for the runtime row to decide whether HMR belongs + # in the first graph. The node half then scans this tree, composes + # window.__DSH_BOOT__, and serves /plugins/<id>/client.js; the browser half + # is the module table the shell kernel constructs before cordis exists + # (adopted as a plugin entry by the kernel, never fetched). - id: modules name: '@deepseek-ai/dsh-client-modules' + inject: [webClientRoster] # Owns both ends of the web transport: node half binds the gateway to the # webserver under /api; browser half is the fetch/SSE client. diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 5385fd1d66..c93f6ec597 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -25,11 +25,10 @@ import type {} from '@deepseek-ai/dsh-bash-env' /** Stable Cordis plugin name. */ export const name = 'web-app' -/** The client-plugin reload chain row this bundle ships disabled, for `--dev`. */ -const HMR_ROW_ID = 'client-hmr' - /** This dsh installation's root, from either this package's source or built entry. */ const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) +const HMR_ROW_ID = 'client-hmr' +const CLIENT_ROSTER_SERVICE = 'webClientRoster' /** Services required before the web runtime can mount. */ export const inject = ['httpServer'] @@ -118,14 +117,16 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex * variables, and the URL line. * @param ctx - plugin context carrying the httpServer service. * @param config - validated {@link Config}. - * @returns nothing once optional development rows are active and runtime contributions are registered. + * @returns nothing once the invocation's client roster and runtime contributions are registered. */ export async function apply(ctx: Context, config: Config): Promise<void> { - ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) - // The client-plugin reload chain is a row this bundle ships off, because it - // exists only in development. Turning it on belongs here rather than in the - // startup row: it needs host services that also activate after webStartup. + // Client discovery must start after the optional HMR row has a pending + // fiber. Otherwise its first browser graph omits the reload receiver, which + // cannot use that receiver to discover itself later. if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID) + // Release client discovery only after the optional row has a pending fiber. + ctx.provide(CLIENT_ROSTER_SERVICE, true) + ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) if (config.surfaceContext) { ctx.inject(['systemPrompt'], (promptCtx) => { addHarnessSourceSection(promptCtx, SOURCE_ROOT) diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 1962710b5e..8c2539a20f 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -49,6 +49,19 @@ function fakeHttpServer(): { server: HttpServerService; seat: () => unknown } { return { server, seat: () => fallback } } +/** Install the optional HMR row the runtime sequences before client discovery. */ +function provideHmrRow(ctx: Context, settle: () => Promise<void> = async () => {}): string[] { + const updates: string[] = [] + ctx.provide('loader', { + entries: () => [{ + options: { id: 'client-hmr' }, + enableRuntime: async () => { updates.push('client-hmr') }, + }], + await: settle, + } as never) + return updates +} + interface BashContribution { name: string variables: Record<string, { description: string }> @@ -68,14 +81,7 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) - const hmrUpdates: unknown[] = [] - ctx.provide('loader', { - entries: () => [{ - options: { id: 'client-hmr' }, - update: async (options: unknown) => { hmrUpdates.push(options) }, - }], - await: async () => {}, - } as never) + const enabledRows = provideHmrRow(ctx) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] })) await ctx.plugin(SystemPrompt, { persona: '' }) @@ -83,7 +89,8 @@ describe('web-app runtime glue', () => { await new Promise(resolve => setTimeout(resolve, 0)) expect(seat()).toBeDefined() // frontend-static claimed the fallback - expect(hmrUpdates).toEqual([{ disabled: false }]) + expect(enabledRows).toEqual(['client-hmr']) + expect(ctx.get('webClientRoster')).toBe(true) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)') const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') @@ -148,7 +155,7 @@ describe('web-app runtime glue', () => { // this row itself has activated. const ready = new Context() ready.provide('httpServer', fakeHttpServer().server) - ready.provide('loader', { await: () => Promise.resolve() } as never) + provideHmrRow(ready) let announce: () => void ready.provide('appReady', new Promise<void>((resolve) => { announce = resolve })) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -182,7 +189,7 @@ describe('web-app runtime glue', () => { settled.provide('httpServer', fakeHttpServer().server) let release: () => void const settlement = new Promise<void>((resolve) => { release = resolve }) - settled.provide('loader', { await: () => settlement } as never) + provideHmrRow(settled, () => settlement) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await new Promise(resolve => setTimeout(resolve, 0)) @@ -202,7 +209,7 @@ describe('web-app runtime glue', () => { await child let releaseTorn: () => void const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve }) - torn.provide('loader', { await: () => tornSettlement } as never) + provideHmrRow(torn, () => tornSettlement) await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) await child.dispose() // the httpServer service goes away releaseTorn!() diff --git a/vendor/README.md b/vendor/README.md index 0666143b54..87e65ed07f 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -46,6 +46,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. 15. **`@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). 16. **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`. +17. **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`. ## Sync procedure diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 4eef5505e1..3fc74177f9 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -73,6 +73,8 @@ export class Entry { _initTask?: Promise<void> _disposing = 0 + private runtimeEnabled = false + private runtimeEnableTask?: Promise<void> constructor(public loader: Loader) { this.ctx = loader.ctx.extend({ [Entry.key]: this }) @@ -99,15 +101,31 @@ export class Entry { private _disabled(options: EntryOptions) { // group is always enabled if (options.group) return false - if (options.disabled) return true + if (options.disabled && !this.runtimeEnabled) return true let entry = this.parent.ctx.fiber.entry while (entry) { - if (entry.options.disabled) return true + if (entry.options.disabled && !entry.runtimeEnabled) return true entry = entry.parent.ctx.fiber.entry } return false } + /** + * Enable this in-memory entry without rewriting its configured `disabled` + * value; the override survives config reapplication for this entry object. + * @returns a promise settling after its initial activation attempt. + */ + enableRuntime(): Promise<void> { + if (this.runtimeEnableTask !== undefined) return this.runtimeEnableTask + this.runtimeEnabled = true + this.runtimeEnableTask = this.refresh().catch((error: unknown) => { + this.runtimeEnabled = false + this.runtimeEnableTask = undefined + throw error + }) + return this.runtimeEnableTask + } + evaluate(expr: string) { return evaluate(this.ctx, expr) } From 18328ce615fb7a2e1defd1bce911f50f68ec1c61 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Mon, 10 Aug 2026 19:59:24 +0800 Subject: [PATCH 164/229] refactor(cli): remove the unused profile preparation hook --- apps/cli/src/profile-boot.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index a3159d4f49..4d7b525d07 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -191,8 +191,6 @@ export interface RunProfileOptions { patchFiles: readonly string[] /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */ args: readonly string[] - /** Host setup registered after Loader installation and before any config-tree entry mounts. */ - prepare?: (ctx: Context) => Promise<void> | void } /** Re-throw setup failures unless this invocation's signal already owns shutdown. */ @@ -271,7 +269,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const watchProfilePatch = !oneShot // Cloned for the same insert-aliasing reason as composeLive: the boot // application must not mutate the objects later reloads recompose from. - const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => { + const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => { app.current = hostCtx // Before any config-tree entry mounts, so plugins resolve all launch-time // environment values from the same immutable provenance snapshot. @@ -292,7 +290,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con } hostCtx.provide('headlessIo', io) } - await options.prepare?.(hostCtx) }).catch((cause: unknown) => { bootFailed(cause) throw cause From b374c16facc68607794557847891c63f533674c7 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Mon, 10 Aug 2026 19:59:25 +0800 Subject: [PATCH 165/229] test(agent-loop): wait for asynchronous reload startup --- .../tests/config-session-id.spec.ts | 44 +++++++------------ 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 40b226f165..4ac477d42f 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -113,27 +113,19 @@ describe('config-driven session id', () => { const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), provider: 'mock', model: 'mock' }] } const firstLoop = await ctx.plugin(AgentLoop, config) - let first: Agent | undefined - for (let i = 0; i < 50 && first === undefined; i++) { - await new Promise(resolve => setTimeout(resolve, 5)) - first = ctx.agents.get(SessionId('config-exact-reload')) - } - expect(first).toBeDefined() - first!.followup(createUserMessage({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })) - await waitForIdle(ctx, first!) + await expect.poll(() => ctx.agents.get(SessionId('config-exact-reload')), { timeout: 5_000 }).toBeDefined() + const first = ctx.agents.get(SessionId('config-exact-reload'))! + first.followup(createUserMessage({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })) + await waitForIdle(ctx, first) await firstLoop.dispose() const secondLoop = await ctx.plugin(AgentLoop, config) - let second: Agent | undefined - for (let i = 0; i < 50 && second === undefined; i++) { - await new Promise(resolve => setTimeout(resolve, 5)) - second = ctx.agents.get(SessionId('config-exact-reload')) - } - expect(second).toBeDefined() - expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me') - second!.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) - await waitForIdle(ctx, second!) - await ctx.sessions.flush(second!.session) + await expect.poll(() => ctx.agents.get(SessionId('config-exact-reload')), { timeout: 5_000 }).toBeDefined() + const second = ctx.agents.get(SessionId('config-exact-reload'))! + expect(JSON.stringify(second.session.deriveMessages())).toContain('remember me') + second.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) + await waitForIdle(ctx, second) + await ctx.sessions.flush(second.session) const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload')) expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2) @@ -423,18 +415,14 @@ describe('config-driven session id', () => { await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) - // The deferred resume runs on a microtask after the backend is available. - let resumed: Agent | undefined - for (let i = 0; i < 50 && !resumed; i++) { - await new Promise(r => setTimeout(r, 5)) - resumed = ctx2.agents.get(SessionId('sticky-1')) - } - expect(resumed).toBeDefined() + // The deferred resume runs after the backend is available. + await expect.poll(() => ctx2.agents.get(SessionId('sticky-1')), { timeout: 5_000 }).toBeDefined() + const resumed = ctx2.agents.get(SessionId('sticky-1'))! // The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>), // and the prior turn's user message is in the derived history. - expect(resumed!.id).toBe(SessionId('sticky-1')) - expect(resumed!.session.id).toBe('sticky-1') - const derived = resumed!.session.deriveMessages() + expect(resumed.id).toBe(SessionId('sticky-1')) + expect(resumed.session.id).toBe('sticky-1') + const derived = resumed.session.deriveMessages() expect(JSON.stringify(derived)).toContain('remember me') await ctx2.fiber.dispose() }) From 1ebb12432b468e79d0f068fe5bd3060432e9d020 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Mon, 10 Aug 2026 20:32:00 +0800 Subject: [PATCH 166/229] test(cli): shut down startup fixtures portably --- apps/cli/tests/built-bin.e2e.ts | 34 ++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index c8fc8e8f64..53922c4f59 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -144,8 +144,8 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) { } function requestProfileShutdown( - child: ReturnType<typeof startProfileLifecycle>, - fixture: ProfileLifecycleFixture, + child: Pick<ReturnType<typeof startProfileLifecycle>, 'kill'>, + fixture: Pick<ProfileLifecycleFixture, 'interrupt'>, ): void { if (process.platform === 'win32') { writeFileSync(fixture.interrupt, 'interrupt') @@ -197,6 +197,7 @@ interface StartupFixture { home: string ready: string echo: string + interrupt: string /** An always-running row's echo, used to observe that a user patch reload landed. */ witness: string } @@ -228,11 +229,16 @@ function createStartupFixture(): StartupFixture { '', ].join('\n')) writeFileSync(join(bundleDir, 'waiting.mjs'), [ - "import { writeFileSync } from 'node:fs'", + "import { existsSync, writeFileSync } from 'node:fs'", "import { join } from 'node:path'", "export const name = 'startup-fixture'", 'export function apply(ctx, config = {}) {', - ' const heartbeat = setInterval(() => {}, 1000)', + ' let interrupted = false', + ' const heartbeat = setInterval(() => {', + ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return', + ' interrupted = true', + " process.emit('SIGTERM')", + ' }, 20)', " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))", " writeFileSync(process.env.RAW_READY_FILE, 'ready')", ' ctx.effect(() => () => { clearInterval(heartbeat) })', @@ -276,7 +282,13 @@ function createStartupFixture(): StartupFixture { dsh: { profile: { bundles: ['dsh-startup-bundle'] } }, }, undefined, 2)) writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n') - return { home, ready: join(home, 'ready'), echo: join(home, 'config-echo'), witness: join(home, 'witness') } + return { + home, + ready: join(home, 'ready'), + echo: join(home, 'config-echo'), + interrupt: join(home, 'interrupt'), + witness: join(home, 'witness'), + } } function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { @@ -286,7 +298,11 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { reject: false, timeout: 25_000, killSignal: 'SIGKILL', - env: { DSH_HOME: fixture.home, RAW_READY_FILE: fixture.ready }, + env: { + DSH_HOME: fixture.home, + RAW_READY_FILE: fixture.ready, + RAW_INTERRUPT_FILE: fixture.interrupt, + }, }) } @@ -538,7 +554,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', // The waiting row started once, already carrying the flag value: the // launcher never saw --generation, and the app resolved it first. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') - child.kill('SIGTERM') + requestProfileShutdown(child, fixture) expect((await child).exitCode).toBe(0) } finally { child.kill('SIGKILL') @@ -552,7 +568,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', try { await waitForFile(fixture.ready) expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default') - child.kill('SIGTERM') + requestProfileShutdown(child, fixture) expect((await child).exitCode).toBe(0) } finally { child.kill('SIGKILL') @@ -586,7 +602,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', await waitForFile(fixture.witness) expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded') expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') - child.kill('SIGTERM') + requestProfileShutdown(child, fixture) expect((await child).exitCode).toBe(0) } finally { child.kill('SIGKILL') From 668bdb3d8eba134107d39f11563ba625ed582158 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Mon, 10 Aug 2026 20:49:40 +0800 Subject: [PATCH 167/229] refactor(cmdline): keep readiness in web app --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 4 +- .../2026-08-06-app-owned-command-line.zh.md | 4 +- apps/cli/src/profile-boot.ts | 16 ------- packages/boot/README.i18n.yaml | 4 +- packages/boot/README.md | 2 +- packages/boot/README.zh.md | 2 +- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 1 - packages/boot/cmdline/README.zh.md | 1 - packages/boot/cmdline/src/index.ts | 12 ----- packages/boot/cmdline/tests/cmdline.spec.ts | 4 +- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/src/index.ts | 10 ++--- packages/bundle/web-app/tests/web-app.spec.ts | 45 +++++-------------- scripts/gen-cordis-catalog.ts | 1 - 18 files changed, 34 insertions(+), 88 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index f59ff0b1a8..895019d166 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 8556c2bbe27189a0784edf4b2a376c932807e020 -2026-08-06-app-owned-command-line.zh.md: f5a7be3500f239e03e0f05d724fa53ffaf28e624 +2026-08-06-app-owned-command-line.md: 3dae1cb209ae9083ac6ab6616a140b6f129bc931 +2026-08-06-app-owned-command-line.zh.md: 81750eec8a78a811dd90454d88fa8ed1611dcce6 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 8556c2bbe2..3dae1cb209 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -12,13 +12,13 @@ After profiles, compositions were installable but their command lines were not. The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. -The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`), `ctx.appExit`, and `ctx.appReady`. An app consumes them from its **startup row**. Both the Loader row and plugin inject `cmdlineArgs`; the plugin calls `runStartup(ctx, service, program, plan)` with its own commander program and provides what it resolved as its own service. The Loader-row injection is also the launcher's discovery declaration; there is no parallel bundle-manifest field. Before boot, the launcher rejects nonempty app arguments with no active declaration and any composition with multiple active declarations. The rows the app configures inject that service and read it from their own config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. An app consumes them from its **startup row**. Both the Loader row and plugin inject `cmdlineArgs`; the plugin calls `runStartup(ctx, service, program, plan)` with its own commander program and provides what it resolved as its own service. The Loader-row injection is also the launcher's discovery declaration; there is no parallel bundle-manifest field. Before boot, the launcher rejects nonempty app arguments with no active declaration and any composition with multiple active declarations. The rows the app configures inject that service and read it from their own config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` provides no startup service, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume <session>` / `--session <id>` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. -Two further consequences. Loader mounts sibling rows concurrently, so one row can activate while another still mounts or while the whole boot is rolling back; a row that publishes readiness (the web URL line) therefore awaits `ctx.appReady`. The Web bundle's runtime plugin owns the harness-source prompt section too, so `dsh web` and `dsh --profile web` boot identically without Web-specific launcher setup. +Two further consequences. Loader mounts sibling rows concurrently, so one row can activate while another still mounts or while the whole boot is rolling back; the Web bundle therefore publishes its URL only after its own Loader tree settles. The Web bundle's runtime plugin owns the harness-source prompt section too, so `dsh web` and `dsh --profile web` boot identically without Web-specific launcher setup. ## Why Loader owns the ordering diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index f5a7be3500..81750eec8a 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -12,13 +12,13 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 -新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)、`ctx.appExit` 和 `ctx.appReady`。应用从自己的**启动行**消费它们。Loader 行与插件都注入 `cmdlineArgs`;插件以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。Loader 行的注入同时也是启动器的发现声明,不再需要一份平行的组合包 manifest 字段。启动器会在 boot 前拒绝没有活跃声明却带有非空应用参数的调用,也会拒绝存在多个活跃声明的组合。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。应用从自己的**启动行**消费它们。Loader 行与插件都注入 `cmdlineArgs`;插件以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。Loader 行的注入同时也是启动器的发现声明,不再需要一份平行的组合包 manifest 字段。启动器会在 boot 前拒绝没有活跃声明却带有非空应用参数的调用,也会拒绝存在多个活跃声明的组合。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 不提供启动服务,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume <session>` / `--session <id>`,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 -还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以公布就绪信号的行(web 的 URL 行)会等待 `ctx.appReady`。另外,Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web` 与 `dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。 +还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以 Web 组合包只会在自身的 Loader 配置树结算后公布 URL。另外,Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web` 与 `dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。 ## 为什么由 Loader 持有顺序 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 4d7b525d07..0266f8518f 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -218,17 +218,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const oneShot = headlessRow !== undefined && headlessRow.disabled !== true const app: { current?: Context } = {} - // Readiness for rows that publish it (the web URL line): a row can activate - // before concurrently mounted siblings finish or fail. - let bootSettled: () => void = () => {} - let bootFailed: (reason: unknown) => void = () => {} - const ready = new Promise<void>((resolve, reject) => { - bootSettled = resolve - bootFailed = reject - }) - // Nothing awaits `ready` on a composition that publishes no readiness, and - // an unobserved rejection must not take the process down on its own. - ready.catch(() => {}) const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) const signalShutdown = new AbortController() const interrupt = (code: number): void => { @@ -280,7 +269,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con provideCmdline(hostCtx, { args: options.args, exit: code => void shutdown.shutdown(code), - ready, }) if (oneShot) { const io: HeadlessIo = { @@ -290,12 +278,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con } hostCtx.provide('headlessIo', io) } - }).catch((cause: unknown) => { - bootFailed(cause) - throw cause }) app.current = ctx - bootSettled() // A surface can dispose the whole tree while startup or this post-boot // watcher setup is still in flight. Loader presence and fiber state own // liveness; the local signal fact distinguishes that expected exit race diff --git a/packages/boot/README.i18n.yaml b/packages/boot/README.i18n.yaml index 9be0243c92..0de587115e 100644 --- a/packages/boot/README.i18n.yaml +++ b/packages/boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/README.md -README.md: 58a824a7f4af3c62f09b363f7cae041651c536b2 -README.zh.md: 7357b920a067ce74f6f74a69a241d82895675ee4 +README.md: 79d653260ea4a9d9a4c71a593b41a6a7e17efa14 +README.zh.md: 839be164328ef168cd6ac18bf2f1dcb930dfce3e diff --git a/packages/boot/README.md b/packages/boot/README.md index 58a824a7f4..79d653260e 100644 --- a/packages/boot/README.md +++ b/packages/boot/README.md @@ -7,6 +7,6 @@ The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/ | Package | Role | ctx key | |---|---|---| | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -| `cmdline/` | Launcher-to-app command-line handoff and app-owned startup parsing | `cmdlineArgs`, `appExit`, `appReady` | +| `cmdline/` | Launcher-to-app command-line handoff and app-owned startup parsing | `cmdlineArgs`, `appExit` | The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md); app-owned command lines are documented in [`cmdline/README.md`](cmdline/README.md). diff --git a/packages/boot/README.zh.md b/packages/boot/README.zh.md index 7357b920a0..839be16432 100644 --- a/packages/boot/README.zh.md +++ b/packages/boot/README.zh.md @@ -7,6 +7,6 @@ | 包 | 职责 | ctx 键 | |---|---|---| | `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | -| `cmdline/` | 启动器到应用的命令行交接,以及由应用持有的启动解析 | `cmdlineArgs`、`appExit`、`appReady` | +| `cmdline/` | 启动器到应用的命令行交接,以及由应用持有的启动解析 | `cmdlineArgs`、`appExit` | 启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md);由应用持有的命令行见 [`cmdline/README.md`](cmdline/README.md)。 diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index db3d559d0a..f5e9413afd 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md -README.md: 571ea7acf9f7be1ee2bdadafae2fc71b99d4536a -README.zh.md: 271acd6be4d58bf12d41bc02dd3ccabc7359a269 +README.md: a1512ae3357f06cd4de6347ea5ec2197fea40a90 +README.zh.md: e27060db433e5c234febb28d6c120d75f82072cc diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 571ea7acf9..a1512ae335 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -10,7 +10,6 @@ A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which - `ctx.cmdlineArgs` — the invocation's inner arguments. `get()` is the whole interface, and it returns a snapshot: `dsh --profile tui --resume abc` yields `['--resume', 'abc']`. - `ctx.appExit` — a bounded process-exit request, wired to the launcher's shutdown controller. -- `ctx.appReady` — settles when the launcher has finished mounting, for a row that publishes readiness (a URL line a supervisor waits for). An embedding host with no command line provides an empty list; that is the honest answer, not a missing value. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index 271acd6be4..e27060db43 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -10,7 +10,6 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属 - `ctx.cmdlineArgs`:本次调用的内层参数。`get()` 就是它的全部接口,返回一份快照:`dsh --profile tui --resume abc` 得到 `['--resume', 'abc']`。 - `ctx.appExit`:一个有边界的进程退出请求,接到启动器的关停控制器上。 -- `ctx.appReady`:在启动器挂载完毕时结算,供需要公布就绪信号的行使用(例如督程会等待的 URL 行)。 没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index 1e2c9e3d0b..6806e0a273 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -53,8 +53,6 @@ declare module 'cordis' { cmdlineArgs?: CmdlineArgs /** Bounded process-exit request; provided by a launcher before the tree mounts. */ appExit?: AppExit - /** Settles when the launcher has mounted the whole composition; see {@link CmdlineHost.ready}. */ - appReady?: Promise<void> } } @@ -64,15 +62,6 @@ export interface CmdlineHost { args: readonly string[] /** Bounded process-exit request. */ exit: AppExit - /** - * Settles when the launcher has finished mounting, which a row that - * publishes readiness (a URL line a supervisor waits for) must await. - * - * Loader mounts sibling rows concurrently, so one row can become active - * while another is still mounting or while the whole boot is rolling back. - * Rejects with the boot failure. - */ - ready?: Promise<void> } /** @@ -86,7 +75,6 @@ export function provideCmdline(ctx: Context, host: CmdlineHost): void { const snapshot = [...host.args] ctx.provide('cmdlineArgs', { get: () => snapshot }) ctx.provide('appExit', host.exit) - if (host.ready !== undefined) ctx.provide('appReady', host.ready) } /** diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index 9c046d4b94..61a5d75197 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -297,11 +297,9 @@ describe('provideCmdline', () => { it('hands the app a snapshot the caller cannot mutate afterwards', () => { const ctx = new Context() const args = ['--resume', 'abc'] - const ready = Promise.resolve() - provideCmdline(ctx, { args, exit: () => {}, ready }) + provideCmdline(ctx, { args, exit: () => {} }) args.push('--tampered') expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) - expect(ctx.appReady).toBe(ready) }) it('fails loud when a startup row runs without the launcher values', () => { diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 6053356414..7f12af35c8 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: 47b582225e768ac035d12947939c7a7eb700458c -README.zh.md: 61e134f90e7ae57cb6220e92880c001f0d06bae2 +README.md: e2cca9ddcca5690f36ce3e952a2814767acdad43 +README.zh.md: 321f7853c821f262a38b35530a4df8b2e18fff49 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 47b582225e..e2cca9ddcc 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model Experience diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 61e134f90e..321f7853c8 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型体验 diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index c93f6ec597..30edbdcb68 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -159,10 +159,10 @@ export async function apply(ctx: Context, config: Config): Promise<void> { const port = ctx.httpServer.port console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) } - // A launcher tells this row when the whole concurrent composition is up; - // this row's own activation can precede a sibling failure. A hand-built - // tree falls back to Loader settlement, or prints at once without Loader. - const settled = ctx.get('appReady') ?? ctx.get('loader')?.await() + // This row's own activation can precede a sibling failure. The app owns + // readiness by waiting for its Loader tree, or prints at once in a + // hand-built context without Loader. + const settled = ctx.get('loader')?.await() if (settled === undefined) printUrl() else { void settled.then(() => { @@ -170,7 +170,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> { // SIGTERM); a URL line for a dead server would only mislead, and // reading the torn-down port would turn a clean shutdown into a crash. if (ctx.get('httpServer') !== undefined) printUrl() - // A failed boot is reported by the launcher; this row only stays quiet. + // Loader reports a failed boot; this row only stays quiet. }, () => {}) } } diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 8c2539a20f..df34637cab 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -149,39 +149,7 @@ describe('web-app runtime glue', () => { await ctx.fiber.dispose() }) - it('waits for launcher readiness and stays quiet when the whole boot failed', async () => { - stageDist() - // Launcher readiness covers siblings that may still be mounting after - // this row itself has activated. - const ready = new Context() - ready.provide('httpServer', fakeHttpServer().server) - provideHmrRow(ready) - let announce: () => void - ready.provide('appReady', new Promise<void>((resolve) => { announce = resolve })) - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await apply(ready, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) - await new Promise(resolve => setTimeout(resolve, 0)) - expect(log).not.toHaveBeenCalled() - announce!() - await new Promise(resolve => setTimeout(resolve, 0)) - expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') - await ready.fiber.dispose() - - // A boot that failed announces nothing: the launcher reports it, and a URL - // for a process that is about to exit would only mislead. - log.mockClear() - const failed = new Context() - failed.provide('httpServer', fakeHttpServer().server) - const rejection = Promise.reject(new Error('boot failed')) - rejection.catch(() => {}) - failed.provide('appReady', rejection) - await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) - await new Promise(resolve => setTimeout(resolve, 0)) - expect(log).not.toHaveBeenCalled() - await failed.fiber.dispose() - }) - - it('defers the URL line until Loader settlement and drops it when the server is gone', async () => { + it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => { stageDist() // Settlement path: the line waits for loader.await() so supervisors can // RPC immediately after observing it. @@ -199,6 +167,17 @@ describe('web-app runtime glue', () => { expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await settled.fiber.dispose() + // Failed path: Loader reports the sibling failure; the app prints no URL + // for a process that is about to exit. + log.mockClear() + const failed = new Context() + failed.provide('httpServer', fakeHttpServer().server) + provideHmrRow(failed, async () => { throw new Error('boot failed') }) + await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).not.toHaveBeenCalled() + await failed.fiber.dispose() + // Torn-down path: settlement resolves after the webserver is gone — no // line, no crash. log.mockClear() diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 3de7e389a8..79419be5ed 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -112,7 +112,6 @@ export const SERVICE_PAGE: Record<string, string> = { export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = { agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle', appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract', - appReady: 'not a service: launcher-provided whole-composition readiness promise — packages/boot/cmdline/README.md owns the launcher contract', cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract', configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract', launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract', From 09e2d2ddc1e32fb6ee0189f7afb1db54168488d1 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Mon, 10 Aug 2026 21:49:11 +0800 Subject: [PATCH 168/229] refactor(cmdline): make command providers ordinary --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 14 +- .../2026-08-06-app-owned-command-line.zh.md | 14 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 10 +- apps/cli/reference/README.zh.md | 10 +- apps/cli/src/args.ts | 10 +- apps/cli/src/profile-boot.ts | 30 +-- apps/cli/tests/args.spec.ts | 4 +- apps/cli/tests/built-bin.e2e.ts | 44 ++-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 15 +- docs/config-catalog.zh.md | 15 +- docs/user/develop/basic/publish.i18n.yaml | 4 +- docs/user/develop/basic/publish.md | 11 +- docs/user/develop/basic/publish.zh.md | 11 +- docs/user/guide/config.i18n.yaml | 4 +- docs/user/guide/config.md | 2 +- docs/user/guide/config.zh.md | 2 +- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 24 +-- packages/boot/cmdline/README.zh.md | 24 +-- packages/boot/cmdline/package.json | 6 +- packages/boot/cmdline/src/index.ts | 169 +++------------- packages/boot/cmdline/src/invariant.ts | 12 +- packages/boot/cmdline/tests/cmdline.spec.ts | 121 +++-------- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 4 +- packages/bundle/headless/README.zh.md | 4 +- packages/bundle/headless/cordis.patch.yml | 12 +- packages/bundle/headless/src/index.ts | 2 +- packages/bundle/headless/src/startup.ts | 39 ++-- .../bundle/headless/tests/startup.spec.ts | 30 +-- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 43 ++-- packages/bundle/web-app/src/index.ts | 58 ++++-- packages/bundle/web-app/src/startup.ts | 117 ++--------- packages/bundle/web-app/tests/startup.spec.ts | 191 ++++++------------ .../web-app/tests/trusted-hosts.spec.ts | 7 +- packages/bundle/web-app/tests/web-app.spec.ts | 36 ++-- pnpm-lock.yaml | 7 +- 46 files changed, 400 insertions(+), 742 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 895019d166..15abf1380b 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 3dae1cb209ae9083ac6ab6616a140b6f129bc931 -2026-08-06-app-owned-command-line.zh.md: 81750eec8a78a811dd90454d88fa8ed1611dcce6 +2026-08-06-app-owned-command-line.md: 4a05cac5ed7f44fb55c2d4498bf28a43befdb073 +2026-08-06-app-owned-command-line.zh.md: 86a37f416d17c4615152b29d73f171803f24c4c3 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 3dae1cb209..4a05cac5ed 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -12,9 +12,9 @@ After profiles, compositions were installable but their command lines were not. The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. -The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. An app consumes them from its **startup row**. Both the Loader row and plugin inject `cmdlineArgs`; the plugin calls `runStartup(ctx, service, program, plan)` with its own commander program and provides what it resolved as its own service. The Loader-row injection is also the launcher's discovery declaration; there is no parallel bundle-manifest field. Before boot, the launcher rejects nonempty app arguments with no active declaration and any composition with multiple active declarations. The rows the app configures inject that service and read it from their own config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program, plan)` with its own commander program, and provide the returned value as an app-owned service. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. -The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` provides no startup service, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. +The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume <session>` / `--session <id>` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. @@ -36,16 +36,16 @@ This leaves dependency ordering in Cordis activation and Loader interpolation, w - **Writing the resolved values into each row** (a config update per row, plus a patch layer handed back to the launcher so a reload could not undo it): it worked, but it meant patches travelling from an app to the launcher and back, two mechanisms for one fact, and a recycle whose correctness depended on Loader restart internals. The maintainer rejected the round trip; the service the rows read replaced all of it. - **Releasing rows by clearing their `inject`**: it worked in isolation and failed on the real web tree, because clearing `inject` is exactly what loses the plugin's static injections. The failure is silent until a plugin reads a service it declared. - **Launcher-managed two-pass mounting**: it can make a provider active before readers are applied, but duplicates the composition, makes ordering a launcher concern, and conceals the Loader defect that nested expressions were evaluated in the include context rather than the target row's injected context. -- **The launcher running each bundle's startup function before boot** (no cordis involvement): strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. Using a `cmdlineArgs`-injected startup row keeps one protocol: it is an ordinary row, dumpable and patchable, and a layering bundle disables it like any other. -- **Both apps parsing the same argv** (a custom composition combines Web and one-shot startup rows): two parsers cannot both own `-h`. A composition has exactly one command-line owner, so a layering bundle disables the startup row it absorbs and provides every startup service its retained rows inject. +- **The launcher running each bundle's command function before boot** (no Cordis involvement): strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. An ordinary `cmdlineArgs`-injected provider keeps one protocol and remains dumpable and patchable. +- **A launcher-enforced command-line owner**: rejecting zero or multiple readers would arbitrate overlaps such as `-h`, but `get()` is an immutable read and normal composition may need several app-owned services. Plugins therefore share the snapshot and own any parser interaction through ordinary composition. - **`instanceof CommanderError`**: an out-of-tree plugin brings its own commander copy, so the class identity differs and a printed `--help` was rethrown as a fatal load failure. Commander's control-flow errors are detected structurally instead. ## Consequences - An app's flags, help text, and usage errors live with the rows they configure; adding a flag to an installed plugin needs no launcher change. - The launcher still recognizes the headless runner for one-shot process lifetime and the telemetry row for its environment switch; neither path interprets app arguments. -- `--help` leaves every row that depends on a startup service pending and requests bounded exit; unrelated rows may activate concurrently before teardown. A profile with no active row injecting `cmdlineArgs` rejects nonempty app arguments before mounting instead of ignoring them. -- A startup service has no statically declared owner: a bundle shipping reading rows without its startup row fails at settlement with pending entries naming the service, not at load. +- `--help` leaves every row that depends on the provider's service pending and requests bounded exit; unrelated rows may activate concurrently before teardown. +- An app-owned service has no statically declared provider: a bundle shipping consumer rows without that provider fails at settlement with pending entries naming the service, not at load. - A user patch that replaces a row's whole `config` drops its expressions, and with them the flag's precedence for that row. - Launcher flags must precede app arguments; a first app argument equal to `web` or `plugin` selects that subcommand instead, `-V`/`--version` remains launcher-owned before that boundary, and the launcher's parser consumes one `--`, so a literal `--` for the app needs `-- --`. -- `--dump-config` never runs a startup row, so it prints the composition before any app argument is resolved and rejects an invocation that carries app arguments. +- `--dump-config` never runs app command-line providers, so it prints the composition before any app argument is resolved and rejects an invocation that carries app arguments. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index 81750eec8a..86a37f416d 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -12,9 +12,9 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 -新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。应用从自己的**启动行**消费它们。Loader 行与插件都注入 `cmdlineArgs`;插件以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。Loader 行的注入同时也是启动器的发现声明,不再需要一份平行的组合包 manifest 字段。启动器会在 boot 前拒绝没有活跃声明却带有非空应用参数的调用,也会拒绝存在多个活跃声明的组合。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program, plan)`,再把返回值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 -boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 不提供启动服务,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 +boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume <session>` / `--session <id>`,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 @@ -36,16 +36,16 @@ boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Lo - **把解析出的取值写进每一行**(逐行一次配置更新,外加交还给启动器的一层 patch,使重载无法撤销它):它能工作,但这意味着 patch 在应用与启动器之间来回传递、同一件事有两套机制,以及一套其正确性依赖 Loader 重启内部细节的回收重建。维护者否决了这次往返;供各行读取的服务取代了这一切。 - **通过清空行的 `inject` 来放行**:孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 - **由启动器管理两趟挂载**:它可以让提供方先于读取行激活,但会重复组合、把顺序变成启动器职责,还掩盖了 Loader 的缺陷——嵌套表达式在 include 上下文而不是目标行的注入上下文中求值。 -- **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。使用注入 `cmdlineArgs` 的启动行则只保留一套协议:它就是一个普通的行,可 dump、可 patch,叠加的组合包也能像禁用其他行那样禁用它。 -- **两个应用解析同一份 argv**(自定义组合同时包含 Web 与一次性启动行):两个解析器不可能同时持有 `-h`。一套组合有且只有一个命令行所有者,因此叠加的组合包要禁用被吸收的启动行,并提供保留下来的各行所注入的全部启动服务。 +- **由启动器在 boot 之前运行每个组合包的命令函数**(完全不经过 Cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。使用注入 `cmdlineArgs` 的普通提供方只保留一套协议,并且仍可 dump、可 patch。 +- **由启动器强制指定命令行所有者**:拒绝零个或多个读取方可以裁决 `-h` 等重叠项,但 `get()` 是不可变读取,普通组合也可能需要多个应用自有服务。因此插件共享该快照,并通过普通组合持有各自解析器的交互。 - **`instanceof CommanderError`**:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的 `--help` 会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。 ## 后果 - 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。 - 启动器仍会识别 headless runner 以管理一次性进程生命周期,并识别 telemetry 行以应用环境开关;两条路径都不解析应用参数。 -- `--help` 会让所有依赖启动服务的行保持待处理并请求有边界的退出;无关行可能在拆除前并发激活。没有注入 `cmdlineArgs` 的活跃行的 profile 会在挂载前拒绝非空应用参数,而不是忽略它们。 -- 启动服务没有静态声明的所有者:交付了读取行却缺少对应启动行的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 +- `--help` 会让所有依赖提供方服务的行保持待处理并请求有边界的退出;无关行可能在拆除前并发激活。 +- 应用自有服务没有静态声明的提供方:交付了消费行却缺少对应提供方的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 - 用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉,该行上 flag 的优先级也随之消失。 - 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令;`-V`/`--version` 在该边界之前仍归启动器持有;而且启动器的解析器会消耗掉一个 `--`,因此要给应用传一个字面量 `--` 需要写成 `-- --`。 -- `--dump-config` 从不运行启动行,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。 +- `--dump-config` 从不运行应用命令行提供方,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 67dd461179..04026262c8 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 96c6932a1faf6f5ce9b64e0390e2a4b3dcb55fc4 -README.zh.md: ea80985a8f6ea43bcea45dfe169937388ab25df0 +README.md: 4fae5338a89ce12c2620e123530acf883ae9efff +README.zh.md: a2d086b8ff12fb07f2446fc4162de09739bcdeab diff --git a/apps/cli/README.md b/apps/cli/README.md index 96c6932a1f..4fae5338a8 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -17,7 +17,7 @@ The invoking directory is the default workspace root. The `web` and `headless` p ## App arguments -The launcher parses only its own flags and hands everything after them to the booted profile, where that app's own startup row parses them ([`dsh-cmdline`](../../packages/boot/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments: +The launcher parses only its own flags and hands everything after them to the booted profile, where any injected app plugin may parse the shared immutable snapshot ([`dsh-cmdline`](../../packages/boot/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments: ```sh dsh --profile web --port 8080 # --port belongs to the web app diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index ea80985a8f..a2d086b8ff 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -17,7 +17,7 @@ ## 应用参数 -启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../packages/boot/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点: +启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,任何注入它的应用插件都可以解析这份共享的不可变快照([`dsh-cmdline`](../../packages/boot/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点: ```sh dsh --profile web --port 8080 # --port belongs to the web app diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index bce5b999d5..8f6db85427 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: f28d77ccba7380426df2dd1769e33be0f7256d27 -README.zh.md: 3d8fbae31780c00f05384a1e4010fda2b6ce3246 +README.md: fd0647347312051a1814a5e3464b34032ae70dfc +README.zh.md: afe4b9ba5651e962288ffebbe7c095ad20bcc617 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index f28d77ccba..fd06473473 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -14,11 +14,11 @@ The `web` and `headless` profiles auto-initialize from shipped templates on firs ### App arguments -The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where that app's own startup row parses it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary. +The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary. -A composition mounts once. A Loader row that injects `cmdlineArgs` parses this app's arguments and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the startup service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port. +A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port. -Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. A profile with no active row injecting `cmdlineArgs` accepts no app arguments; it rejects them before mounting any row instead of silently ignoring them. A composition with multiple active rows injecting `cmdlineArgs` is always rejected because two parsers cannot own the same command line. +Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. `ctx.cmdlineArgs.get()` is a shared immutable read: multiple plugins may parse the same snapshot, while a profile with no reader ignores its app arguments. The shipped apps own these command lines: @@ -36,7 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs an app's startup row, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. +`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. ## Plugin management @@ -52,7 +52,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, which owns them in its bundle's startup row. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` adds authorities over the composed fence configuration, and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 3d8fbae317..afe4b9ba56 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -14,11 +14,11 @@ ### 应用参数 -启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../../packages/boot/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。 +启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,任何注入它的应用插件都可以解析([`dsh-cmdline`](../../../packages/boot/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。 -一套组合只挂载一次。注入 `cmdlineArgs` 的 Loader 行解析本应用的参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖启动服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。 +一套组合只挂载一次。普通插件注入 `cmdlineArgs`、解析本应用参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖提供方服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。 -启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。若 profile 中没有注入 `cmdlineArgs` 的活跃行,该 profile 不接受应用参数;启动器会在挂载任何行之前拒绝这些参数,而不是静默忽略。若组合中有多个注入 `cmdlineArgs` 的活跃行,启动器总会拒绝该组合,因为两个解析器不能共同持有同一条命令行。 +启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。`ctx.cmdlineArgs.get()` 是共享的不可变读取:多个插件可以解析同一份快照,没有读取方的 profile 则会忽略自己的应用参数。 随附的各应用持有这些命令行: @@ -36,7 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 从不运行应用的启动行,因此它展示的是任何应用参数被解析之前的组合配置树,并拒绝携带应用参数的调用。 +`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 从不运行应用命令行提供方,因此它展示的是任何应用参数被解析之前的组合配置树,并拒绝携带应用参数的调用。 ## 插件管理 @@ -52,7 +52,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由该应用在其组合包的启动行中持有。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 在组合出的围栏配置之上追加 authority,`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 43d68a2c7c..27d92dcf66 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -3,8 +3,8 @@ * * The launcher parses only what it owns — which profile to boot, which extra * patch overlays to apply, and the config dumps — and hands **everything after - * its own flags** to the booted tree verbatim, where the booted app's startup row - * parses its own flag family and prints its own `--help` (see + * its own flags** to the booted tree verbatim, where injected app plugins parse + * their own flag families and print their own `--help` (see * `@deepseek-ai/dsh-cmdline`). Launcher flags therefore come first: the first * token this parser does not recognize starts the inner arguments, so * `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`, @@ -23,7 +23,7 @@ interface ProfileInvocation { profile: string /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ patches: string[] - /** Everything after the launcher's own flags, verbatim, for the booted app's startup row. */ + /** Everything after the launcher's own flags, verbatim, for injected app plugins. */ args: string[] } @@ -89,8 +89,8 @@ function resolveBoot(program: Command, profile: string, options: BootOptions, ar if (options.dumpConfig === true && options.dumpDefaultConfig === true) { program.error('error: --dump-config and --dump-default-config are mutually exclusive') } - // The dump is boot-free: it never runs the app's startup row, so it cannot - // show what that app's flags would decide, and printing a tree that differs + // The dump is boot-free: it never runs app command-line providers, so it + // cannot show what those flags would decide, and printing a tree that differs // from the same invocation's boot would mislead. if (args.length > 0) { program.error(`error: config dumps take no app arguments, got ${args.map(argument => JSON.stringify(argument)).join(' ')}`) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 0266f8518f..f3c356f199 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -6,8 +6,8 @@ * live, and wire fail-loud plus bounded shutdown. * * App flags are not the launcher's business: the invocation's inner arguments - * are provided to the tree through `ctx.cmdlineArgs`, and the booted app's - * startup row parses them and configures its own rows. + * are provided to the tree through `ctx.cmdlineArgs`, where any injected app + * plugin may read the same immutable snapshot. * @module @deepseek-ai/dsh/profile-boot */ @@ -37,7 +37,7 @@ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', im /** Harness-home directory holding locally authored agent presets. */ const USER_PRESET_DIR = '.agent-presets' import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' -import { hasCmdlineConsumer, provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' import { resolveWindowsShellLayer } from './windows-shell.ts' @@ -206,12 +206,6 @@ function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void */ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> { const composed = composeProfile(options.profile, options.patchFiles) - if (!hasCmdlineConsumer([...composed.rows.values()]) && options.args.length > 0) { - throw new Error( - `${NAME}: profile ${JSON.stringify(options.profile)} takes no app arguments because no active row injects cmdlineArgs; ` - + `got ${options.args.map(argument => JSON.stringify(argument)).join(' ')}`, - ) - } // A one-shot composition ends by itself, which changes what a signal means // and makes watching the user's patch layer pointless. const headlessRow = composed.rows.get(HEADLESS_ROW_ID) @@ -225,8 +219,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con shutdown.interrupt(code) } // Signals own teardown throughout the startup window, not only after boot() - // settles: an inserted startup row can publish readiness before sibling rows - // finish mounting. + // settles: an inserted provider can publish before sibling rows finish mounting. process.on('SIGTERM', () => { interrupt(oneShot ? 143 : 0) }) process.on('SIGINT', () => { interrupt(130) }) installFailLoud(NAME, process, async () => { @@ -235,9 +228,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME) // Recomposition for the live user layers: bundle layers below, overlays - // above, so a user edit can never displace them. What an app's startup row - // resolved is not in here at all — it lives in that row's own service, which - // survives a recomposition. BOTH + // above, so a user edit can never displace them. Parsed app arguments are + // not in here at all — they live in app-provided services that survive a + // recomposition. BOTH // user files are re-read per generation (the HMR watcher hands us only the // changed file's patches, which one of the reads duplicates — fresh reads // keep the two watchers from stitching in each other's stale copy). @@ -263,9 +256,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // Before any config-tree entry mounts, so plugins resolve all launch-time // environment values from the same immutable provenance snapshot. hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment) - // The command line is a launcher fact every app reads the same way: its - // own arguments, and the bounded exit its startup row requests after - // printing help or rejecting them. + // The command line and bounded exit request are launcher facts available + // to every app plugin that injects the argument snapshot. provideCmdline(hostCtx, { args: options.args, exit: code => void shutdown.shutdown(code), @@ -280,8 +272,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con } }) app.current = ctx - // A surface can dispose the whole tree while startup or this post-boot - // watcher setup is still in flight. Loader presence and fiber state own + // A surface can dispose the whole tree while boot or this post-boot watcher + // setup is still in flight. Loader presence and fiber state own // liveness; the local signal fact distinguishes that expected exit race // from a real HMR error. if (watchProfilePatch diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index ce0a5b2ba4..89a16921b2 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -87,8 +87,8 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1) expect(exitCode(['web', '--patch='])).toBe(1) - // A dump never runs the app's startup row, so it cannot show what that - // app's own flags would decide; printing a tree that differs from the same + // A dump never runs app command-line providers, so it cannot show what + // those flags would decide; printing a tree that differs from the same // invocation's boot would mislead. expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1) expect(exitCode(['--profile', 'web', '--dump-config', '-h'])).toBe(1) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 53922c4f59..b9e99604c1 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -128,8 +128,8 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture { return { home, ready, settled, disposed, interrupt } } -function startProfileLifecycle(fixture: ProfileLifecycleFixture) { - return execa(process.execPath, [dshBin, '--profile', 'lifecycle'], { +function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly string[] = []) { + return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], { cwd: fixture.home, input: '', reject: false, @@ -203,9 +203,9 @@ interface StartupFixture { } /** - * A custom profile whose bundle owns a command line: a startup row whose - * `cmdlineArgs` injection identifies it to the launcher, and a row that reads - * what it resolved through a `!!js` config expression. Both plugin modules resolve + * A custom profile whose ordinary provider plugin injects `cmdlineArgs`, plus + * a row that reads its app-owned service through a `!!js` config expression. + * Both plugin modules resolve * `@deepseek-ai/dsh-cmdline` and `commander` through the profile module * fallback, exactly as an installed out-of-tree bundle does. */ @@ -219,12 +219,13 @@ function createStartupFixture(): StartupFixture { mkdirSync(bundleDir, { recursive: true }) writeFileSync(join(bundleDir, 'startup.mjs'), [ "import { Command } from 'commander'", - "import { runStartup } from '@deepseek-ai/dsh-cmdline'", + "import { parseCmdline } from '@deepseek-ai/dsh-cmdline'", "export const name = 'fixture-startup'", "export const inject = ['cmdlineArgs']", 'export function apply(ctx) {', " const program = new Command().name('fixture').option('--generation <value>', 'echoed generation')", - " return runStartup(ctx, 'fixtureStartup', program, parsed => ({ generation: parsed.opts().generation }))", + ' const values = parseCmdline(ctx, program, parsed => ({ generation: parsed.opts().generation }))', + ' if (values !== undefined) ctx.provide(\'fixtureStartup\', values)', '}', '', ].join('\n')) @@ -260,11 +261,10 @@ function createStartupFixture(): StartupFixture { ` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`, ' inject: [fixtureStartup]', ' config:', - // The flag the startup row resolved wins over the value written beside it. - " generation: !!js ctx.get('fixtureStartup')?.generation ?? 'bundle-default'", + // Lazy interpolation runs only after the provider's service is injected. + " generation: !!js ctx.fixtureStartup.generation ?? 'bundle-default'", ' - id: fixture-startup', ` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`, - ' inject: [cmdlineArgs]', ' - id: reload-witness', ` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`, '', @@ -466,21 +466,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) - it('rejects arguments when no active row injects the profile command line', async () => { + it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => { const fixture = createProfileLifecycleFixture() - try { - const result = await runBuiltBin(['--profile', 'lifecycle', '--help'], { DSH_HOME: fixture.home }) - expect(result.code).toBe(1) - expect(result.stderr).toContain('takes no app arguments because no active row injects cmdlineArgs') - expect(existsSync(fixture.ready)).toBe(false) - } finally { - rmSync(fixture.home, { recursive: true, force: true }) - } - }, 30_000) - - it('applies a custom profile bundle and disposes it on a startup-time signal', async () => { - const fixture = createProfileLifecycleFixture() - const child = startProfileLifecycle(fixture) + const child = startProfileLifecycle(fixture, ['--unclaimed']) try { await waitForFile(fixture.ready) requestProfileShutdown(child, fixture) @@ -551,8 +539,8 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const child = startStartupProfile(fixture, ['--generation', 'flagged']) try { await waitForFile(fixture.ready) - // The waiting row started once, already carrying the flag value: the - // launcher never saw --generation, and the app resolved it first. + // The consumer started once, already carrying the flag value: the + // launcher never saw --generation, and the app provider resolved it first. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') requestProfileShutdown(child, fixture) expect((await child).exitCode).toBe(0) @@ -562,7 +550,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) - it('starts a waiting row on its composed value when the invocation carries no app arguments', async () => { + it('starts a consumer on its composed value when the invocation carries no app arguments', async () => { const fixture = createStartupFixture() const child = startStartupProfile(fixture, []) try { @@ -577,7 +565,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', }, 30_000) it('keeps the app arguments across a user patch reload', async () => { - // A live edit recomposes every row while the startup service remains + // A live edit recomposes every row while the provider service remains // active, so each config expression reads the same invocation value (a // served port does not move back to its composed fallback). const fixture = createStartupFixture() diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 3761963233..3957d29f57 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: 64e65e93b165ede2ac6c8fa399b9ce461938b939 -config-catalog.zh.md: 9f4a7ab071d68cfaf8ae3ea42458babfee67c9fd +config-catalog.md: 0813c9e1f1d761b69180bc919d0629e10c7661bc +config-catalog.zh.md: cda44f7904196fe2bf401fed2dc5b5e8b28ccf1c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 64e65e93b1..0813c9e1f1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -572,7 +572,7 @@ Source: [`packages/goal/goal/src/index.ts:116`](../packages/goal/goal/src/index. Requires: `agentDefaultModel` · `agents` · `sessions` ```ts config-catalog -/** Plugin config: the task resolved from this app's injected startup service. */ +/** Plugin config: the task resolved from this app's injected provider service. */ export interface Config { /** The prompt text for the single run. */ task: string @@ -2520,7 +2520,7 @@ Source: [`packages/web/web/src/index.ts:55`](../packages/web/web/src/index.ts) Requires: `httpServer` ```ts config-catalog -/** Plugin config: composed deployment settings plus per-invocation startup values. */ +/** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode @@ -2533,20 +2533,15 @@ export interface Config { * orientation text would be false. */ surfaceContext: boolean - /** - * LAN IPv4 addresses sampled once by the app startup row when the effective bind - * is all-interfaces — the exact snapshot the /api trust fence was - * configured with, so the printed LAN URL can never name an address the - * fence rejects. Empty on a loopback bind. - */ - lanAddresses: string[] + /** Explicit `--trusted-host` authorities from this invocation. */ + trustedHosts: string[] } /** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ export type WebMode = 'production' | 'development' ``` -Source: [`packages/bundle/web-app/src/index.ts:40`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9f4a7ab071..cda44f7904 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -574,7 +574,7 @@ export interface Config { 需要:`agentDefaultModel` · `agents` · `sessions` ```ts config-catalog -/** Plugin config: the task resolved from this app's injected startup service. */ +/** Plugin config: the task resolved from this app's injected provider service. */ export interface Config { /** The prompt text for the single run. */ task: string @@ -2521,7 +2521,7 @@ export interface WebServiceConfig { 需要:`httpServer` ```ts config-catalog -/** Plugin config: composed deployment settings plus per-invocation startup values. */ +/** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode @@ -2534,20 +2534,15 @@ export interface Config { * orientation text would be false. */ surfaceContext: boolean - /** - * LAN IPv4 addresses sampled once by the app startup row when the effective bind - * is all-interfaces — the exact snapshot the /api trust fence was - * configured with, so the printed LAN URL can never name an address the - * fence rejects. Empty on a loopback bind. - */ - lanAddresses: string[] + /** Explicit `--trusted-host` authorities from this invocation. */ + trustedHosts: string[] } /** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ export type WebMode = 'production' | 'development' ``` -来源:[`packages/bundle/web-app/src/index.ts:40`](../packages/bundle/web-app/src/index.ts) +来源:[`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index ebcb75a3ca..91dba947bb 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/publish.md -publish.md: 04520b0fb7d30c716e3c87761bd38f0c25824739 -publish.zh.md: 7b0e0141dc0522bb5ec356aa8cba1618c9517f09 +publish.md: 8437c7ea5c4cb966f9f3d68977949c78986ec9a5 +publish.zh.md: 4409dbfda060a84b316029d87ec985209cfa286a diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 04520b0fb7..8437c7ea5c 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -99,7 +99,7 @@ The effective configuration composes over an empty root by applying, in order: 3. The home-level `$DSH_HOME/cordis.patch.yml` — machine-local preferences shared by every profile. 4. Each `--patch <path>` overlay, in argv order. -App arguments are not another patch layer. A surface bundle can resolve them through a startup service, described below. +App arguments are not another patch layer. A surface bundle can resolve them through an ordinary app-owned service, described below. Later layers win per row, and a patch replaces a row's entire `config` value rather than deep-merging keys. Two consequences for bundle authors: @@ -110,17 +110,16 @@ In-box bundle names always resolve from the dsh installation itself; pnpm manage ## Give a surface bundle its own command line -A bundle that defines a runnable app marks its startup row through the injection it already requires: +A bundle that defines a runnable app mounts an ordinary provider plugin: ```yaml - id: hello-startup name: 'dsh-hello-plugin/startup' - inject: [cmdlineArgs] ``` -That row calls `runStartup` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) with the app's own commander program. The launcher hands it every argument after the launcher flags, so app-specific flags need no launcher change. Loader mounts the composition once, waits for each row's injections, and only then evaluates that row's `!!js` config against its injected context. +The plugin exports `inject = ['cmdlineArgs']`, calls `parseCmdline` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) with its own commander program, and provides the returned value as its app-owned service. The launcher hands every plugin the same immutable arguments after launcher flags, so app-specific flags need no launcher change and multiple plugins may parse the snapshot. The Loader row needs no launcher marker or special kind. -Rows configured by those arguments inject the startup service and read it from their own `!!js` options, with the deployment value beside it as the fallback: +Rows configured by those arguments inject the provider's service and read it from their own `!!js` options, with the deployment value beside it as the fallback: ```yaml - id: my-app @@ -130,7 +129,7 @@ Rows configured by those arguments inject the startup service and read it from t port: !!js ctx.myAppStartup.port ?? 8080 ``` -On `--help`, the service is not provided, so those rows never activate. An app layered over another app disables the lower startup row, because one composition has one command-line owner. +On `--help`, the provider publishes no service, so those rows never activate. Loader mounts the composition once, waits for each row's ordinary injections, and only then evaluates that row's `!!js` config against its injected context. ## Installing from GitHub: the build-script catch diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index 7b0e0141dc..4409dbfda0 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -99,7 +99,7 @@ dsh --profile demo 3. home 级的 `$DSH_HOME/cordis.patch.yml`——各 profile 共享的机器本地偏好。 4. 每个 `--patch <path>` overlay,按 argv 顺序。 -应用参数不是另一层 patch。表层组合包可以通过下文所述的启动服务解析它们。 +应用参数不是另一层 patch。表层组合包可以通过下文所述的普通应用自有服务解析它们。 后应用的层按行胜出,且 patch 会替换目标行的整个 `config` 值,而不是深度合并各键。这给组合包作者带来两个推论: @@ -110,17 +110,16 @@ dsh --profile demo ## 让表层组合包持有自己的命令行 -定义了可运行应用的组合包可以通过启动行本来就需要的注入来标记它: +定义了可运行应用的组合包挂载一个普通提供方插件: ```yaml - id: hello-startup name: 'dsh-hello-plugin/startup' - inject: [cmdlineArgs] ``` -该行使用应用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `runStartup`。启动器把自身 flag 之后的所有参数交给它,因此添加应用专属 flag 无需修改启动器。Loader 只挂载一次组合,等待每一行的注入,再基于其已注入的上下文求值该行的 `!!js` 配置。 +该插件导出 `inject = ['cmdlineArgs']`,使用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `parseCmdline`,再把返回值作为应用自有服务提供出去。启动器把自身 flag 之后的同一份不可变参数交给每个插件,因此添加应用专属 flag 无需修改启动器,多个插件也可以解析该快照。Loader 行不需要启动器标记或特殊类型。 -受这些参数配置的行会注入启动服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退: +受这些参数配置的行会注入提供方服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退: ```yaml - id: my-app @@ -130,7 +129,7 @@ dsh --profile demo port: !!js ctx.myAppStartup.port ?? 8080 ``` -遇到 `--help` 时,该服务不会被提供,所以这些行不会激活。叠加在另一应用之上的应用会禁用下层启动行,因为一套组合只能有一个命令行所有者。 +遇到 `--help` 时,提供方不会发布该服务,所以这些行不会激活。Loader 只挂载一次组合,等待每一行的普通注入,再基于其已注入的上下文求值该行的 `!!js` 配置。 ## 从 GitHub 安装:构建脚本这道坎 diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 3010782d20..00a8867092 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/config.md -config.md: 7a8492d45fc3710958853b8498f90f5a19b62f4a -config.zh.md: 62a1693a13cdd4b2428085187b73b69d429cde6e +config.md: 1d3ad5ce36d4b360ba5156b6be28a6caae4a23d4 +config.zh.md: 7f8bfaa77066f2976a5667e3ac402814a7afdf96 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index 7a8492d45f..1d3ad5ce36 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -51,7 +51,7 @@ Cordis starts sibling entries concurrently. A plugin declares required services ## CLI patch layers -`dsh --profile <name>` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles/<name>/cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and each `--patch <path>` overlay. Later layers win per row. App flags are not another patch layer: the bundle's `cmdlineArgs`-injected startup row resolves them into a service, and rows that retain a `!!js` read of that service give the invocation value precedence. +`dsh --profile <name>` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles/<name>/cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and each `--patch <path>` overlay. Later layers win per row. App flags are not another patch layer: an ordinary bundle plugin injects `cmdlineArgs` and provides parsed values as its own service, while rows that inject and retain a `!!js` read of that service give the invocation value precedence. A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 62a1693a13..7f8bfaa770 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -51,7 +51,7 @@ Cordis 会并发启动同级配置项。插件通过 `inject` 声明必需服务 ## CLI 补丁层 -`dsh --profile <name>` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles/<name>/cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 与每个 `--patch <path>` overlay。同一行以较后的层为准。应用 flag 并不是另一层 patch:组合包中注入 `cmdlineArgs` 的启动行把它们解析成服务,而保留了读取该服务的 `!!js` 表达式的行会让本次调用的取值优先。 +`dsh --profile <name>` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles/<name>/cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 与每个 `--patch <path>` overlay。同一行以较后的层为准。应用 flag 并不是另一层 patch:组合包中的普通插件注入 `cmdlineArgs`,再把解析值作为自身服务提供;注入该服务并保留其 `!!js` 读取的行会让本次调用的取值优先。 补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index f5e9413afd..6a032582cf 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md -README.md: a1512ae3357f06cd4de6347ea5ec2197fea40a90 -README.zh.md: e27060db433e5c234febb28d6c120d75f82072cc +README.md: 98335e901bdf8fe33e14c1ad4c1a320d77f30c96 +README.zh.md: 28ea749943c60089c6b4725cb61e121f82aa0114 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index a1512ae335..98335e901b 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -13,30 +13,28 @@ A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which An embedding host with no command line provides an empty list; that is the honest answer, not a missing value. -## Startup rows, and the service their app reads +## Ordinary providers and injected config -An app reads those arguments from its **startup row** — a Loader row and plugin that inject `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`: +Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program, plan)` is only a commander adapter; the caller owns the returned value and service: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] export function apply(ctx: Context): void { - runStartup(ctx, 'webStartup', webCommand(), planWebStartup) + const values = parseCmdline(ctx, webCommand(), planWebStartup) + if (values !== undefined) ctx.provide('webStartup', values) } ``` -The Loader-row injection is also its discovery declaration, so no bundle manifest field is needed: +Its Loader row carries no launcher marker or special kind: ```yaml - id: web-startup name: '@deepseek-ai/dsh-web-app/startup' - inject: [cmdlineArgs] ``` -The launcher uses that injection only to reject arguments for a composition with no command-line owner, and to reject a composition with multiple owners. Loader mounts the composition once and holds each row until its own injections are active. - -Every row the app configures from flags then reads what the startup row resolved, naming the key it takes and the value it falls back to: +Every row configured from those values uses ordinary service injection and direct lazy config access: ```yaml - id: webserver @@ -47,9 +45,7 @@ Every row the app configures from flags then reads what the startup row resolved port: !!js ctx.webStartup.port ?? 3080 ``` -`runStartup` parses the arguments, asks `plan` for the values, and provides them as the service. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text and requests exit — nothing is provided, so rows that depend on the startup service never activate. - -`plan` receives the startup context and the options of every row that injects the service, for a value that has to take the composition into account. Include still holds nested expressions raw at this point, so a plan that needs a composed fallback can interpolate the relevant row config against the pre-service startup context; the `/api` fence authorities are the shipped example. +`parseCmdline` parses the immutable arguments and asks `plan` for the app-owned value. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, requests exit, and returns `undefined`; the provider publishes nothing, so dependent rows never activate. ### How injection orders config @@ -57,9 +53,9 @@ Loader defers a row's `!!js` interpolation until that row's declared injections `enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). The activation is an in-memory override: it does not rewrite the row's configured `disabled` value and survives config reapplication for that mounted entry. Loader applies the enabled row's ordinary injection ordering. -### One command line, one owner +### Shared immutable arguments -A composition has exactly one command-line owner. An app that layers over another one disables the underlying startup row and provides every startup service its retained rows inject. +`get()` does not consume or mutate argv. Multiple plugins can parse the same snapshot and independently provide services. The launcher does not inspect the composition for a command-line owner; a profile with no reader simply ignores its app arguments. An out-of-tree plugin brings its own commander copy, so commander's control-flow errors are detected structurally rather than by class identity; an identity check would rethrow a printed help as a fatal load failure. @@ -74,5 +70,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Launcher flags must precede app arguments.** The split is positional: the first token the launcher does not recognize starts the inner arguments, so `--patch` placed after an app flag belongs to the app. The launcher's parser consumes one `--`, so an app argument that must survive as a literal `--` needs `-- --`. -- **A startup service has no declared owner.** Reading rows name it and a `cmdlineArgs` consumer provides it; nothing links those two injections statically, so a bundle that ships reading rows without its startup row fails at settlement (pending entries naming the service) rather than at load. +- **An app-owned service has no statically declared provider.** Consumer rows name it through ordinary injection; a bundle that omits its provider fails at settlement with pending entries naming the service rather than at load. - **A user patch that replaces a row's whole `config` drops its expressions.** A flag beats the value written beside it, not a literal a user wrote in place of the expression; keeping the expression is what keeps the flag winning. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index e27060db43..28ea749943 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -13,30 +13,28 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属 没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 -## 启动行,以及它的应用所读取的服务 +## 普通提供方与注入配置 -应用从自己的**启动行**读取这些参数:这是一个在 Loader 行与插件中都注入 `cmdlineArgs`,并调用 `runStartup(ctx, service, program, plan)` 的插件: +任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program, plan)` 只适配 commander;返回值与服务都归调用方持有: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] export function apply(ctx: Context): void { - runStartup(ctx, 'webStartup', webCommand(), planWebStartup) + const values = parseCmdline(ctx, webCommand(), planWebStartup) + if (values !== undefined) ctx.provide('webStartup', values) } ``` -Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字段: +它的 Loader 行不携带启动器标记,也没有特殊类型: ```yaml - id: web-startup name: '@deepseek-ai/dsh-web-app/startup' - inject: [cmdlineArgs] ``` -启动器只用该注入来拒绝那些没有命令行所有者却带有应用参数的组合,以及拒绝存在多个所有者的组合。Loader 只挂载一次整套组合,并让每一行等待自身的注入激活。 - -应用用 flag 配置的每一行随后读取启动行解析出的取值,各自点名自己取用的键,以及回退时使用的值: +所有由这些取值配置的行都使用普通服务注入,并在惰性配置中直接访问该服务: ```yaml - id: webserver @@ -47,9 +45,7 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字 port: !!js ctx.webStartup.port ?? 3080 ``` -`runStartup` 解析参数,向 `plan` 索取取值,并把它们作为服务提供出去。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本并请求退出:什么也不会被提供,因此依赖启动服务的行不会激活。 - -`plan` 会收到启动上下文,以及所有注入该服务的行的选项,用于那些必须顾及组合本身的取值。此时 Include 仍保留着嵌套表达式的原始形态,因此需要组合回退值的 plan 可以基于服务提供前的启动上下文插值相关行配置;随附的例子是 `/api` 栅栏 authority。 +`parseCmdline` 解析不可变参数,再向 `plan` 索取应用自有取值。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 文本、请求退出并返回 `undefined`;提供方什么也不发布,因此依赖行不会激活。 ### 注入如何排列配置求值 @@ -57,9 +53,9 @@ Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活 `enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。该激活是内存中的覆盖:它不会改写行所配置的 `disabled` 值,并会在已挂载条目的配置重新应用后继续生效。Loader 会对启用后的行应用普通的注入顺序。 -### 一条命令行,一个所有者 +### 共享不可变参数 -一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的启动行,并提供保留下来的各行所注入的全部启动服务。 +`get()` 不会消费或修改 argv。多个插件可以解析同一份快照,并分别提供服务。启动器不会检查组合中的命令行所有者;没有读取方的 profile 只会忽略自己的应用参数。 树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。 @@ -74,5 +70,5 @@ Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活 ## 已知限制与延期工作 - **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。 -- **启动服务没有声明所有者**:读取行点名它,由 `cmdlineArgs` 消费方提供它;这两种注入之间没有静态关联,因此交付了读取行却缺少对应启动行的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。 +- **应用自有服务没有静态声明的提供方**:消费行通过普通注入点名它;缺少提供方的组合包会在结算时失败,由待处理条目点名该服务,而不是在加载时失败。 - **用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉**:flag 胜过的是表达式旁写着的那个值,而不是用户用字面量替换掉表达式之后的结果;保留表达式才能保留 flag 的优先级。 diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 90af8c212f..28647bfc24 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-cmdline", - "description": "Command-line handoff between a dsh launcher and app bundles: cmdlineArgs exposes inner arguments, while injected startup rows parse them into app-owned runtime services", + "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", "version": "0.0.1", "private": true, "type": "module", @@ -24,9 +24,6 @@ "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", - "dependencies": { - "commander": "^15.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-invariants": "^0.0.1", @@ -35,6 +32,7 @@ "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" } diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index 6806e0a273..1236b32cb5 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -7,22 +7,17 @@ * {@link CmdlineArgs} service, so an app owns its flag family, its `--help` * text, and its parse errors instead of the launcher knowing them. * - * An app consumes those arguments from a **startup plugin**: a row that - * injects `cmdlineArgs` and calls {@link runStartup}. What that plugin resolves - * becomes its own service, and the rows it configures read the values from - * there — `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats - * the value written beside it. Nothing is handed back to the launcher. - * - * Loader delays each row's config interpolation until its declared injections - * are active. A startup row consumes `cmdlineArgs`, provides the app's resolved - * values, and thereby activates only the rows that depend on those values. + * Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A + * provider may publish the parsed values as its own service, and ordinary rows + * can inject that service and read it from lazily resolved config — + * `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written + * beside it. No row has launcher-level command-line status. * @module @deepseek-ai/dsh-cmdline */ import type { Command } from 'commander' import type { Context } from 'cordis' -import type { Entry, EntryOptions } from '@cordisjs/plugin-loader' -// Empty type import carries the loader Context merge used to walk the tree. +// Empty type import carries the Loader Context merge used by enableRow. import type {} from '@cordisjs/plugin-loader' /** @@ -72,44 +67,11 @@ export interface CmdlineHost { * @param host - the invocation's arguments and its exit request. */ export function provideCmdline(ctx: Context, host: CmdlineHost): void { - const snapshot = [...host.args] + const snapshot: readonly string[] = Object.freeze([...host.args]) ctx.provide('cmdlineArgs', { get: () => snapshot }) ctx.provide('appExit', host.exit) } -/** - * Detect whether an active row consumes the launcher's command line. - * - * The Loader-row injection is the declaration: an active row that names - * `cmdlineArgs` owns startup for this composition. No bundle manifest field or - * plugin import is needed, so an out-of-tree app adds its command line by - * adding the same injection its startup plugin already requires. - * @param rows - the composed Loader rows. - * @returns whether this composition has a command-line owner. - * @throws when more than one active row claims the command line. - */ -export function hasCmdlineConsumer(rows: readonly EntryOptions[]): boolean { - const consumers: string[] = [] - const visit = (entries: readonly EntryOptions[], ancestorDisabled = false, prefix = ''): void => { - for (const row of entries) { - const id = prefix + row.id - // Loader group containers stay active when disabled, but their children - // inherit that disabled state. - const active = row.group === true || (!ancestorDisabled && row.disabled !== true) - if (active && waitsForAny(row.inject, ['cmdlineArgs'])) consumers.push(id) - if (row.group === true && Array.isArray(row.config)) { - visit(row.config, ancestorDisabled || row.disabled === true, `${id}:`) - } - } - } - visit(rows) - if (consumers.length > 1) { - const ids = consumers.map(id => JSON.stringify(id)).join(', ') - throw new Error(`dsh-cmdline: multiple active rows inject cmdlineArgs (${ids}); disable all but one startup row`) - } - return consumers.length === 1 -} - /** The process streams commander output is written to; production writes to the process. */ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { write(chunk: string): unknown } } = { stdout: process.stdout, @@ -117,58 +79,36 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w } /** - * Resolve this invocation into the values the app's rows read. - * - * Runs after a successful parse, with the waiting rows' composed options - * available for a value that has to take the composition into account (the - * `/api` fence authorities are the shipped example). Call `program.error(...)` - * to reject the invocation with a usage message instead of throwing. + * Resolve parsed arguments into an app-owned value. Call + * `program.error(...)` to reject the invocation with a usage message instead + * of throwing. * @param program - the parsed commander program. - * @param rows - the waiting rows' composed options, in tree order. - * @param ctx - the startup row's context, for resolving composed fallbacks before the service exists. - * @returns the service value the app's rows read; `undefined` keys let a row's - * own fallback stand. + * @param ctx - the plugin context that received the command line. + * @returns the value an ordinary provider plugin may publish. */ -export type StartupPlan<T = unknown> = (program: Command, rows: readonly EntryOptions[], ctx: Context) => T +export type CmdlinePlan<T = unknown> = (program: Command, ctx: Context) => T /** - * Run one app's startup: parse the invocation's inner arguments with the app's - * own commander program and provide the resolved values as `service`. The - * Loader then activates the rows that were waiting for the provided service. + * Parse the launcher's immutable argument snapshot with an app's commander + * program. The caller decides whether and how to publish the returned value; + * this helper has no Loader-row or service ownership semantics. * - * The rows read their values from the service, so nothing is written into - * their config from here: a row asks for `ctx.<service>.<key>` and - * falls back to the value written beside it, which is why a flag wins. Loader - * resolves a row's config only after its injections are active. A live - * recomposition reads the service that remains active, so editing a user patch - * cannot reset an invocation value. - * - * Help, version, and rejected arguments are terminal for the process: the text - * is written, the service is never provided, dependent rows stay pending, and - * `ctx.appExit` is requested. - * - * A custom app that layers over another one disables the underlying startup - * row and names every startup service its retained rows inject, because a - * composition has exactly one command-line owner. - * @param ctx - plugin context carrying `cmdlineArgs`, `appExit`, and the Loader. - * @param services - the service name, or names, this startup row provides. + * Help, version, and rejected arguments are terminal for the process: commander + * writes the text, the helper requests `ctx.appExit`, and it returns + * `undefined` so the caller publishes nothing. + * @param ctx - plugin context carrying `cmdlineArgs` and `appExit`. * @param program - the app's commander program, with its flags and description already declared. - * @param plan - this invocation's resolved values; omitted provides an empty value. - * @returns the resolved values, or `undefined` when the app asked to exit - * instead (help, version, or arguments it rejected). - * @throws when the launcher provided no command line, or when a named service - * is injected by no row. + * @param plan - this invocation's resolved value; omitted returns an empty object. + * @returns the resolved value, or `undefined` when the app asked to exit. + * @throws when the launcher did not provide the command line and exit request. */ -export function runStartup<T>( +export function parseCmdline<T>( ctx: Context, - services: string | readonly string[], program: Command, - plan: StartupPlan<T> = (() => ({}) as T), + plan: CmdlinePlan<T> = (() => ({}) as T), ): T | undefined { - const names = typeof services === 'string' ? [services] : services - // Read through the global service store, not the property proxy: these are - // optional host values, and a row that injects only `cmdlineArgs` may not - // read the others as declared injections. + // Read through the global service store, not the property proxy: appExit is + // an optional host value and the plugin only needs to inject cmdlineArgs. const args = ctx.get('cmdlineArgs') const exit = ctx.get('appExit') if (args === undefined || exit === undefined) { @@ -180,26 +120,17 @@ export function runStartup<T>( writeOut: text => void internals.stdout.write(text), writeErr: text => void internals.stderr.write(text), }) - let values: T try { program.parse(args.get(), { from: 'user' }) - // An app can dispose the whole tree while this row is still parsing (an - // early SIGTERM, or another app exiting). There is then nothing to resolve - // and nothing to start, and the check below would blame the bundle for a - // tree that simply went away. - if (ctx.get('loader') === undefined) return undefined - values = plan(program, waitingRows(ctx, names), ctx) + return plan(program, ctx) } catch (error) { // exitOverride turns help, version, a parse error, and a plan's own // program.error() into a CommanderError; commander has already written the - // text through the output configured above. With no startup service, - // dependent rows remain pending and the app stays unstarted. + // text through the output configured above. if (!isCommanderError(error)) throw error exit(error.exitCode) return undefined } - for (const service of names) ctx.provide(service, values) - return values } /** @@ -224,34 +155,6 @@ export async function enableRow(ctx: Context, id: string): Promise<void> { await entry.enableRuntime() } -/** - * The composed options of every row waiting on one of `services`, in tree order. - * @param ctx - plugin context whose Loader tree carries the rows. - * @param services - the startup service names. - * @returns the waiting rows' options. - * @throws when a service is injected by no row, which means the bundle patch - * and its startup plugin disagree. - */ -function waitingRows(ctx: Context, services: readonly string[]): EntryOptions[] { - for (const service of services) { - if (waitingEntries(ctx, [service]).length === 0) { - throw new Error(`${service}: no row injects this startup service — the bundle patch must set "inject: [${service}]" on every row this app configures`) - } - } - return waitingEntries(ctx, services).map(entry => entry.options) -} - -/** - * The Loader entries waiting on any of `services`. - * @param ctx - plugin context whose Loader tree carries the rows. - * @param services - the startup service names. - * @returns the waiting entries in tree order. - */ -function waitingEntries(ctx: Context, services: readonly string[]): Entry[] { - // Called only after runStartup established the tree is still live. - return [...ctx.loader.entries()].filter(entry => waitsForAny(entry.options.inject, services)) -} - /** * Whether a thrown value is commander's own control-flow error (help, version, * a parse error, or `program.error`). @@ -269,17 +172,3 @@ function isCommanderError(error: unknown): error is { code: string; exitCode: nu return typeof candidate.code === 'string' && candidate.code.startsWith('commander.') && typeof candidate.exitCode === 'number' } - -/** - * Whether a row's `inject` declaration names any of `services`. - * @param inject - the row's `inject` value: the array form, the object form, or absent. - * @param services - the startup service names. - * @returns true when the row waits for one of them. - */ -function waitsForAny(inject: EntryOptions['inject'], services: readonly string[]): boolean { - if (inject === undefined || inject === null) return false - // The array form lists service names; the object form maps each name to its - // intercept config. Both name the service as a key of the same shape. - const declared = Array.isArray(inject) ? inject : Object.keys(inject) - return services.some(service => declared.includes(service)) -} diff --git a/packages/boot/cmdline/src/invariant.ts b/packages/boot/cmdline/src/invariant.ts index f1ec75678f..cab932a8e6 100644 --- a/packages/boot/cmdline/src/invariant.ts +++ b/packages/boot/cmdline/src/invariant.ts @@ -14,14 +14,10 @@ export const name = 'cmdline-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the owned relation is "no row is left waiting for a - * startup service", which is a property of the whole tree at Loader - * settlement, and the invariant service carries no settlement signal to - * evaluate it at. Observing it from the entry stream would fire while startup - * is still parsing, when every waiting row is legitimately still waiting. The - * launcher's post-settlement audit (`assertEntriesActivated`) already reports - * a startup service that was never provided as a pending entry naming it, and - * the built-bin e2e asserts the apps boot with flag values applied. + * No runtime invariant: `cmdlineArgs` is an immutable launcher fact that any + * number of ordinary plugins may read. App-owned providers and consumers use + * normal Cordis service injection, whose missing dependencies are already + * reported by Loader settlement. */ const install: InvariantInstaller = () => {} diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index 61a5d75197..9faf6ed7d5 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -15,7 +15,7 @@ import Include from '@cordisjs/plugin-include' import type { PatchOptions } from '@cordisjs/plugin-include' import { afterEach, describe, expect, it } from 'vitest' import { - enableRow, hasCmdlineConsumer, internals, provideCmdline, runStartup, type StartupPlan, + enableRow, internals, parseCmdline, provideCmdline, type CmdlinePlan, } from '../src/index.ts' /** Every value one boot of the fixture tree observed. */ @@ -26,7 +26,7 @@ interface Observed { out: string } -/** A booted fixture tree: what it observed, and its root for direct startup calls. */ +/** A booted fixture tree: what it observed, and its root for direct parser calls. */ interface Fixture { observed: Observed ctx: Context @@ -46,7 +46,7 @@ function demoCommand(): Command { } /** The fixture app's plan: the resolved values its rows read. */ -const demoPlan: StartupPlan<{ port?: number }> = (program) => { +const demoPlan: CmdlinePlan<{ port?: number }> = (program) => { const port = program.opts<{ port?: string }>().port if (port === undefined) return {} if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`) @@ -65,8 +65,8 @@ const expression = (source: string): unknown => ({ __jsExpr: source }) */ async function bootFixture( args: string[], - plan: StartupPlan = demoPlan, - options: { objectInject?: boolean; withoutStartup?: boolean } = {}, + plan: CmdlinePlan = demoPlan, + options: { objectInject?: boolean; withoutProvider?: boolean } = {}, ): Promise<Fixture> { const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) const observed: Observed = { exits: [], out: '' } @@ -81,28 +81,31 @@ export function apply(ctx, config) { globalThis.__observed.started = config } writeFileSync(join(dir, 'startup.mjs'), ` export const name = 'demo-startup' export const inject = ['cmdlineArgs'] -export function apply(ctx) { return globalThis.__runStartup(ctx) } +export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) } `) writeFileSync(join(dir, 'cordis.yml'), '[]\n') const observing = { write: (chunk: string) => { observed.out += chunk; return true } } internals.stdout = observing internals.stderr = observing - const globals = globalThis as unknown as { __observed: Observed; __runStartup: (ctx: Context) => void } + const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void } globals.__observed = observed - globals.__runStartup = (ctx: Context) => { runStartup(ctx, 'demoStartup', demoCommand(), plan) } + globals.__provideDemoArgs = (ctx: Context) => { + const values = parseCmdline(ctx, demoCommand(), plan) + if (values !== undefined) ctx.provide('demoStartup', values) + } // The composition, exactly as a profile delivers one: include patches whose // config carries `!!js` expressions. const composition: PatchOptions[] = [{ insert: [ - ...options.withoutStartup === true + ...options.withoutProvider === true ? [] - : [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'startup.mjs')).href, inject: ['cmdlineArgs'] }], + : [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'startup.mjs')).href }], { id: 'reader', name: pathToFileURL(join(dir, 'reader.mjs')).href, inject: options.objectInject === true ? { demoStartup: { required: true } } : ['demoStartup'], - config: { port: expression('ctx.demoStartup?.port ?? 3080') }, + config: { port: expression('ctx.demoStartup.port ?? 3080') }, }, ], }] @@ -119,55 +122,7 @@ export function apply(ctx) { return globalThis.__runStartup(ctx) } return { observed, ctx } } -describe('hasCmdlineConsumer', () => { - it('recognizes active array and object injections', () => { - expect(hasCmdlineConsumer([ - { id: 'ordinary', name: 'ordinary' }, - { id: 'disabled-startup', name: 'disabled-startup', inject: ['cmdlineArgs'], disabled: true }, - { id: 'tui-startup', name: 'tui-startup', inject: { cmdlineArgs: { required: true } } }, - ])).toBe(true) - expect(hasCmdlineConsumer([ - { id: 'ordinary', name: 'ordinary' }, - { id: 'disabled-startup', name: 'disabled-startup', inject: ['cmdlineArgs'], disabled: true }, - ])).toBe(false) - expect(() => hasCmdlineConsumer([ - { id: 'web-startup', name: 'web-startup', inject: ['cmdlineArgs'] }, - { id: 'tui-startup', name: 'tui-startup', inject: ['cmdlineArgs'] }, - ])).toThrow('multiple active rows inject cmdlineArgs ("web-startup", "tui-startup")') - }) - - it('walks nested groups and ignores consumers disabled by an ancestor', () => { - expect(hasCmdlineConsumer([{ - id: 'app', - name: 'cordis:group', - group: true, - config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], - }])).toBe(true) - expect(hasCmdlineConsumer([{ - id: 'app', - name: 'cordis:group', - group: true, - disabled: true, - config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], - }])).toBe(false) - expect(() => hasCmdlineConsumer([ - { - id: 'first', - name: 'cordis:group', - group: true, - config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], - }, - { - id: 'second', - name: 'cordis:group', - group: true, - config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }], - }, - ])).toThrow('multiple active rows inject cmdlineArgs ("first:startup", "second:startup")') - }) -}) - -describe('runStartup', () => { +describe('parseCmdline', () => { it('lets a row read the flag value the app resolved', async () => { const { observed } = await bootFixture(['--port', '8080']) expect(observed.started).toEqual({ port: 8080 }) @@ -179,7 +134,7 @@ describe('runStartup', () => { expect(observed.started).toEqual({ port: 3080 }) }) - it('recognizes the Loader object form of a startup-service injection', async () => { + it('recognizes the Loader object form of a provider-service injection', async () => { const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true }) expect(observed.started).toEqual({ port: 8080 }) }) @@ -199,32 +154,24 @@ describe('runStartup', () => { }) it('rethrows a plan failure that is not commander asking to exit', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) - const plan: StartupPlan = () => { throw new Error('plan exploded') } - expect(() => { runStartup(ctx, 'demoStartup', demoCommand(), plan) }).toThrow('plan exploded') + const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) + const plan: CmdlinePlan = () => { throw new Error('plan exploded') } + expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan exploded') }) it('rethrows a thrown value that is not an object at all', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) - const plan: StartupPlan = () => { + const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) + const plan: CmdlinePlan = () => { const thrown: unknown = 'plan threw a string' throw thrown } - expect(() => { runStartup(ctx, 'demoStartup', demoCommand(), plan) }).toThrow('plan threw a string') + expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan threw a string') }) - it('fails loud when no row injects the service the app provides', async () => { - // The bundle patch and its startup row disagree; a silent no-op would leave - // every row of the app on its fallbacks with no explanation. - const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) - expect(() => { runStartup(ctx, 'absentStartup', demoCommand()) }) - .toThrow('absentStartup: no row injects this startup service') - }) - - it('accepts a service-name list when the app declares no plan', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true }) - runStartup(ctx, ['demoStartup'], demoCommand()) - expect(ctx.get('demoStartup')).toEqual({}) + it('returns values without inspecting Loader rows or owning a service', async () => { + const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) + expect(parseCmdline(ctx, demoCommand())).toEqual({}) + expect(ctx.get('demoStartup')).toBeUndefined() }) }) @@ -302,19 +249,17 @@ describe('provideCmdline', () => { expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) }) - it('fails loud when a startup row runs without the launcher values', () => { + it('fails loud when a parser runs without the launcher values', () => { const ctx = new Context() - expect(() => { runStartup(ctx, 'demoStartup', demoCommand()) }) + expect(() => { parseCmdline(ctx, demoCommand()) }) .toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit') }) - it('resolves nothing when the tree was disposed while the startup row parsed', () => { - // An early SIGTERM takes the Loader with it; there is nothing left to - // configure, and the bundle did nothing wrong. - const exits: number[] = [] + it('lets multiple parsers read the same immutable snapshot', () => { const ctx = new Context() - provideCmdline(ctx, { args: [], exit: code => void exits.push(code) }) - expect(() => { runStartup(ctx, 'demoStartup', demoCommand()) }).not.toThrow() - expect(exits).toEqual([]) + provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} }) + expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) + expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) + expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true) }) }) diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index f64ead7a50..4377802ae4 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/headless/README.md -README.md: 459d0f32788265d43e75922067da3c03d054f444 -README.zh.md: e3ca9d13512e3a13ac71c5cda650fca958609062 +README.md: 31a4894dbb191d2244371ca7272339e96e253053 +README.zh.md: 6e8d28f10071fbab175c4f14f1aaa9618b8f598a diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 459d0f3278..31a4894dbb 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected startup service). It mounts no Host, HTTP server, Web runtime, or browser plugin. +The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin. -After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the `headless-startup` row ([`src/startup.ts`](src/startup.ts)) reads it as the positional argument of `dsh --profile headless "task"` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), prints the app's `--help`, and rejects an invocation with no task instead of letting the runner's schema fail. +After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates. ## Model Experience diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index e3ca9d1351..6e8d28f100 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的启动服务解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 +dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 -Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:`headless-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))把它读作 `dsh --profile headless "task"` 的位置参数,打印应用自己的 `--help`,并拒绝没有任务的调用,而不是让 runner 的 schema 失败。 +Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。 ## 模型体验 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 2c03de11af..8d2e1ff4ab 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -1,8 +1,8 @@ # The dsh-headless bundle patch: one-shot task mode directly over dsh-base. -# It mounts no Host, HTTP server, Web runtime, or browser plugin. The startup -# row injects `cmdlineArgs`, owns the task positional -# (`dsh --profile headless "<task>"`) and this app's --help; the direct driver -# creates an Agent through the core registry and prints its durable result. +# It mounts no Host, HTTP server, Web runtime, or browser plugin. An ordinary +# provider plugin injects `cmdlineArgs`, parses the task positional +# (`dsh --profile headless "<task>"`) and this app's --help, then the direct +# driver creates an Agent through the core registry and prints its durable result. - id: system-prompt config: @@ -25,10 +25,8 @@ - id: headless-startup name: '@deepseek-ai/dsh-headless/startup' - inject: [cmdlineArgs] - # Reads its task from the headlessStartup service after the startup row - # resolves this app's command line. + # Reads its task from the ordinary headlessStartup provider. - id: headless-runner name: '@deepseek-ai/dsh-headless' inject: [headlessStartup] diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 92dc62d7ff..284f948aac 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -25,7 +25,7 @@ export const name = 'headless-runner' /** Core services required before the one-shot turn can start. */ export const inject = ['agentDefaultModel', 'agents', 'sessions'] -/** Plugin config: the task resolved from this app's injected startup service. */ +/** Plugin config: the task resolved from this app's injected provider service. */ export interface Config { /** The prompt text for the single run. */ task: string diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index e960c63554..7999bb09d2 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -1,16 +1,13 @@ /** - * The one-shot app's startup row: it owns the `dsh --profile headless` command - * line — the task text is this command's positional argument — and its - * `--help` text, then provides {@link HEADLESS_STARTUP_SERVICE} with the task - * the user asked for. The runner waits for it, so a missing task is a usage - * error printed by this command instead of a schema failure inside the runner. + * The one-shot app's command-line provider: it parses the task positional and + * `--help`, then publishes {@link HEADLESS_STARTUP_SERVICE}. The runner is an + * ordinary consumer whose lazy config waits for that service. * @module @deepseek-ai/dsh-headless/startup */ import { Command } from 'commander' import type { Context } from 'cordis' -import type { EntryOptions } from '@cordisjs/plugin-loader' -import { runStartup } from '@deepseek-ai/dsh-cmdline' +import { parseCmdline } from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ export const name = 'headless-startup' @@ -18,12 +15,9 @@ export const name = 'headless-startup' /** Services required before the task can be resolved. */ export const inject = ['cmdlineArgs'] -/** The service this row provides and the one-shot runner row reads. */ +/** Service provided by this plugin and injected by the one-shot runner. */ export const HEADLESS_STARTUP_SERVICE = 'headlessStartup' -/** The row that runs the task, and the only reason this app has a command line. */ -const RUNNER_ROW_ID = 'headless-runner' - /** What the runner row reads from {@link HEADLESS_STARTUP_SERVICE}. */ export interface HeadlessStartupValues { /** The task text this invocation asked for. */ @@ -47,27 +41,22 @@ Examples: } /** - * Turn the parsed command line into the runner row's task. + * Turn the parsed command line into the runner's task. * @param program - the parsed headless command. - * @param rows - the rows waiting on this app's service, in tree order. - * @returns the runner row's service value. - * @throws when the composition has no runner row, which would otherwise accept - * a task and silently run nothing. + * @returns the runner's service value. */ -function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): HeadlessStartupValues { +function planHeadlessStartup(program: Command): HeadlessStartupValues { const task = program.args.join(' ') - if (task === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') - if (!rows.some(row => row.id === RUNNER_ROW_ID)) { - throw new Error(`headless-startup: the composition has no waiting "${RUNNER_ROW_ID}" row to run the task`) - } + if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') return { task } } /** - * Resolve the task for the runner waiting on `headlessStartup`. - * @param ctx - plugin context carrying the command line and the Loader. - * @returns nothing once the runner is started, or once `--help` or a missing task requested exit. + * Parse and provide the one-shot task as an ordinary Cordis service. + * @param ctx - plugin context carrying the command line. + * @returns nothing once the task is provided, or when the command requested exit. */ export function apply(ctx: Context): void { - runStartup(ctx, HEADLESS_STARTUP_SERVICE, headlessCommand(), planHeadlessStartup) + const values = parseCmdline(ctx, headlessCommand(), planHeadlessStartup) + if (values !== undefined) ctx.provide(HEADLESS_STARTUP_SERVICE, values) } diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 51c6708c8f..dce5387b84 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -1,7 +1,7 @@ /** - * The one-shot app's startup row over a real Loader tree: the task positional - * becomes the injected runner config, while help and usage errors leave the - * runner pending. + * The one-shot app's ordinary command-line provider over a real Loader tree: + * the task becomes injected runner config, while help and usage errors leave + * the consumer pending. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -31,15 +31,11 @@ afterEach(async () => { }) /** - * Mount the real startup row over a runner stand-in. + * Mount the real provider over a runner stand-in. * @param args - the invocation's inner arguments. - * @param options - fixture knobs for invalid compositions. - * @returns the resolved startup value and observed runner/process effects. + * @returns the resolved service value and observed runner/process effects. */ -async function bootStartup( - args: string[], - options: { withoutRunner?: boolean } = {}, -): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> { +async function bootStartup(args: string[]): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-')) const observed: Observed = { exits: [], out: '' } writeFileSync(join(dir, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n') @@ -52,14 +48,13 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) `) const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href writeFileSync(join(dir, 'cordis.yml'), [ - options.withoutRunner === true ? '- id: displaced-runner' : '- id: headless-runner', + '- id: headless-runner', ` name: ${rowUrl}`, ` inject: [${HEADLESS_STARTUP_SERVICE}]`, ' config:', ' task: !!js ctx.headlessStartup.task', '- id: headless-startup', ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, - ' inject: [cmdlineArgs]', '', ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } @@ -85,7 +80,7 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) } } -describe('headless startup', () => { +describe('headless command-line provider', () => { it('joins the task positional into the runner config', async () => { const { task, observed } = await bootStartup(['run', 'the', 'tests']) expect(task).toEqual({ task: 'run the tests' }) @@ -93,8 +88,8 @@ describe('headless startup', () => { expect(observed.exits).toEqual([]) }) - it('rejects an invocation with no task and leaves the runner pending', async () => { - const { task, observed } = await bootStartup([]) + it.each([{ args: [] }, { args: [' '] }])('rejects an invocation with no non-whitespace task ($args)', async ({ args }) => { + const { task, observed } = await bootStartup(args) expect(observed.out).toContain('a task is required') expect(task).toBeUndefined() expect(observed.runnerConfig).toBeUndefined() @@ -108,9 +103,4 @@ describe('headless startup', () => { expect(observed.runnerConfig).toBeUndefined() expect(observed.exits).toEqual([0]) }) - - it('fails when the composition has no runner row', async () => { - await expect(bootStartup(['task'], { withoutRunner: true })) - .rejects.toThrow('the composition has no waiting "headless-runner" row') - }) }) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 7f12af35c8..9040315855 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: e2cca9ddcca5690f36ce3e952a2814767acdad43 -README.zh.md: 321f7853c821f262a38b35530a4df8b2e18fff49 +README.md: b6fa225f5e0a0a079605a4fb9064b79287ab21cd +README.zh.md: 68af959719b9bd146eddd143aa9d98400e65fa68 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index e2cca9ddcc..b6fa225f5e 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, `--dev`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model Experience diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 321f7853c8..68af959719 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、`--dev`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型体验 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 37b19e7645..199c46a33b 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -5,12 +5,11 @@ # A patch replaces the targeted row's whole `config`, so each row below # restates every key it owns. # -# Rows this app configures from flags read them from the `webStartup` service: -# each names the key it takes and the value it falls back to, so a flag wins -# over the value written beside it. The web-startup row injects `cmdlineArgs` -# and provides `webStartup`; Loader delays dependent-row config interpolation -# until that service is active. `dsh --profile web --help` provides no service, -# so the server rows never activate. +# The web-startup plugin injects `cmdlineArgs` and provides `webStartup` as an +# ordinary Cordis service. Rows configured from flags inject that service, so +# Loader resolves their expressions only after it exists. The web runtime then +# provides bind-dependent `webRuntime` values to the trust fence and client +# roster. `dsh --profile web --help` provides neither service, so no server binds. # ── surface-specific values the base deliberately omits ───────────────────── @@ -81,39 +80,39 @@ - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' - # This app's command-line startup row. It owns the web flag family and its - # --help, and provides webStartup to the rows that inject it. + # Ordinary provider for the parsed Web flags. Its plugin-level injection + # waits for cmdlineArgs; no launcher metadata or special row kind is needed. - id: web-startup name: '@deepseek-ai/dsh-web-app/startup' - inject: [cmdlineArgs] # ── layer 2: transport/service ────────────────────────────────────────────── # Plain route-registration carrier; host and port come from the app's - # startup service, with these deployment fallbacks. The dist is served by + # webStartup provider, with these deployment fallbacks. The dist is served by # the web-runtime row below through the fallback seat. - id: webserver name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: - host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1' - port: !!js ctx.get('webStartup')?.port ?? 3080 + host: !!js ctx.webStartup.host ?? '127.0.0.1' + port: !!js ctx.webStartup.port ?? 3080 # Web glue owned by this bundle: resolves the built frontend dist (an # assembly fact of dsh-web-app, never user config), mounts the # frontend-static fallback owner, registers the web-surface prompt - # section and bash runtime variables, and prints the URL line. `dsh web` - # patches mode/lanAddresses over these defaults. A complete agent-preset + # section and bash runtime variables, and prints the URL line. The webStartup + # provider supplies invocation-only values; after the server binds, this row + # samples LAN trust once and provides `webRuntime`. A complete agent-preset # persona suppresses the prompt section for that agent while retaining # these host-owned shell variables. - id: web-runtime name: '@deepseek-ai/dsh-web-app' inject: [webStartup] config: - mode: !!js ctx.get('webStartup')?.mode ?? 'production' + mode: !!js ctx.webStartup.mode printUrl: true surfaceContext: true - lanAddresses: !!js ctx.get('webStartup')?.lanAddresses ?? [] + trustedHosts: !!js ctx.webStartup.trustedHosts # The client-plugin reload chain: a dev-only row this bundle ships off, # which the runtime row turns on before client discovery. It is a row rather @@ -133,18 +132,18 @@ # (adopted as a plugin entry by the kernel, never fetched). - id: modules name: '@deepseek-ai/dsh-client-modules' - inject: [webClientRoster] + inject: [webRuntime] # Owns both ends of the web transport: node half binds the gateway to the # webserver under /api; browser half is the fetch/SSE client. - id: connection name: '@deepseek-ai/dsh-client-connection' - inject: [webStartup] + inject: [webRuntime] config: - # The LAN literals an all-interfaces bind derived plus the - # --trusted-host extras. A deployment that configures its own fence - # authorities adds them to this list. - trustedHosts: !!js ctx.get('webStartup')?.trustedHosts ?? [] + # LAN literals derived from the active bind plus --trusted-host extras. + # A deployment adding authorities keeps this expression and concatenates + # its literals, for example: ['app.internal', ...ctx.webRuntime.trustedHosts]. + trustedHosts: !!js ctx.webRuntime.trustedHosts - id: api-remotes name: '@deepseek-ai/dsh-api-remotes' diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 30edbdcb68..0a8ec7ffbb 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -11,6 +11,7 @@ */ import { createRequire } from 'node:module' +import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' @@ -28,7 +29,9 @@ export const name = 'web-app' /** This dsh installation's root, from either this package's source or built entry. */ const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) const HMR_ROW_ID = 'client-hmr' -const CLIENT_ROSTER_SERVICE = 'webClientRoster' + +/** Runtime service that releases Web rows after bind-dependent values resolve. */ +const WEB_RUNTIME_SERVICE = 'webRuntime' /** Services required before the web runtime can mount. */ export const inject = ['httpServer'] @@ -36,7 +39,7 @@ export const inject = ['httpServer'] /** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ export type WebMode = 'production' | 'development' -/** Plugin config: composed deployment settings plus per-invocation startup values. */ +/** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode @@ -49,22 +52,25 @@ export interface Config { * orientation text would be false. */ surfaceContext: boolean - /** - * LAN IPv4 addresses sampled once by the app startup row when the effective bind - * is all-interfaces — the exact snapshot the /api trust fence was - * configured with, so the printed LAN URL can never name an address the - * fence rejects. Empty on a loopback bind. - */ - lanAddresses: string[] + /** Explicit `--trusted-host` authorities from this invocation. */ + trustedHosts: string[] } export const Config: z<Config> = z.object({ mode: z.union([z.const('production'), z.const('development')]).default('production'), printUrl: z.boolean().default(true), surfaceContext: z.boolean().default(true), - lanAddresses: z.array(String).default([]), + trustedHosts: z.array(String).default([]), }) +/** Bind-dependent Web values shared by the trust fence and URL display. */ +export interface WebRuntimeValues { + /** LAN IPv4 literals sampled once when the server binds all interfaces. */ + lanAddresses: string[] + /** LAN literals followed by explicit invocation authorities. */ + trustedHosts: string[] +} + /** Environment variable naming the canonical local URL of this Web GUI. */ const DSH_WEB_URL = 'DSH_WEB_URL' as const /** Environment variable naming the Web runtime mode. */ @@ -73,6 +79,27 @@ const DSH_WEB_MODE = 'DSH_WEB_MODE' as const // Display-only mirror of the webserver schema's loopback host: the address the // local URL always prints. Not a source of truth — the schema is. const LOOPBACK_HOST = '127.0.0.1' +/** The webserver schema's all-interfaces bind literal. */ +const ALL_INTERFACES_HOST = '0.0.0.0' + +/** + * Resolve one LAN-trust snapshot from the active server bind. + * + * Derived entries are port-less IP literals: DNS rebinding needs an + * attacker-controlled name, while an IP-literal Host is safe on any port and + * an OS-assigned port is unknowable before bind. + * @param bindHost - the active webserver bind host. + * @param extra - explicit `--trusted-host` values, in argument order. + * @returns the LAN display addresses and invocation-derived fence authorities. + */ +export function resolveLanTrust(bindHost: string, extra: readonly string[]): WebRuntimeValues { + const lanAddresses = bindHost === ALL_INTERFACES_HOST + ? Object.values(networkInterfaces()).flat() + .filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal) + .map(iface => iface.address) + : [] + return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } +} /** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */ function webSurfacePrompt(webUrl: string, mode: WebMode): string { @@ -124,8 +151,10 @@ export async function apply(ctx: Context, config: Config): Promise<void> { // fiber. Otherwise its first browser graph omits the reload receiver, which // cannot use that receiver to discover itself later. if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID) - // Release client discovery only after the optional row has a pending fiber. - ctx.provide(CLIENT_ROSTER_SERVICE, true) + const runtime = resolveLanTrust(ctx.httpServer.host, config.trustedHosts) + // Release dependent rows only after the optional row has a pending fiber and + // bind-dependent trust has been sampled once. + ctx.provide(WEB_RUNTIME_SERVICE, runtime) ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) if (config.surfaceContext) { ctx.inject(['systemPrompt'], (promptCtx) => { @@ -153,9 +182,8 @@ export async function apply(ctx: Context, config: Config): Promise<void> { // sibling rows (the /api route owner) are still mounting. Await Loader // settlement first; a hand-built tree without a Loader prints at once. const printUrl = (): void => { - // The startup row's boot-time LAN snapshot, not a fresh sample: the printed - // LAN URL must name an address the /api trust fence was configured with. - const lanCandidate = config.lanAddresses[0] + // Reuse the exact LAN snapshot provided to the /api trust fence. + const lanCandidate = runtime.lanAddresses[0] const port = ctx.httpServer.port console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) } diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index a276e7e032..78d24f553a 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -1,18 +1,14 @@ /** - * The web app's startup row: it owns the `dsh --profile web` flag family - * (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help` text, - * turns those flags into changes on the rows that inject - * {@link WEB_STARTUP_SERVICE}, and then provides it. Until it does, no - * flag-configured web row starts, so `dsh --profile web --help` prints this - * command's help and the server never binds. + * The web app's command-line provider: it parses the `dsh --profile web` flag + * family (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help` + * text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}. + * Ordinary rows inject that service before reading it from lazy config. * @module @deepseek-ai/dsh-web-app/startup */ -import { networkInterfaces } from 'node:os' import { Command } from 'commander' import type { Context } from 'cordis' -import { interpolate, type EntryOptions } from '@cordisjs/plugin-loader' -import { runStartup } from '@deepseek-ai/dsh-cmdline' +import { parseCmdline } from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ export const name = 'web-startup' @@ -20,11 +16,7 @@ export const name = 'web-startup' /** Services required before the flags can be resolved. */ export const inject = ['cmdlineArgs'] -/** - * The service this row provides and every flag-configured web row reads. The - * rows are listed in this bundle's `cordis.patch.yml`, where each names the key - * it takes from here and the value it falls back to. - */ +/** Service provided by this ordinary plugin and injected by flag-configured rows. */ export const WEB_STARTUP_SERVICE = 'webStartup' /** What the web rows read from {@link WEB_STARTUP_SERVICE}. */ @@ -35,63 +27,8 @@ export interface WebStartupValues { port?: number /** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */ mode: 'production' | 'development' - /** - * The `/api` fence authorities for this invocation: the LAN literals an - * all-interfaces bind derived, plus the `--trusted-host` extras, over what - * the composition already configured. - */ + /** Explicit `--trusted-host` authorities, in argument order. */ trustedHosts: string[] - /** The LAN literals the fence was configured with, for display. */ - lanAddresses: string[] -} - -/** The webserver schema's all-interfaces bind literal: only this bind derives LAN authorities. */ -const ALL_INTERFACES_HOST = '0.0.0.0' - -/** - * Read the deployment trust list before its row mounts and validates config. - * @param config - the connection row's config resolved before `webStartup` exists. - * @returns its configured authorities, or an empty list when absent. - * @throws when the file-backed config is not an array of strings. - */ -function configuredTrustedHosts(config: unknown): string[] { - const value = (config as { trustedHosts?: unknown } | undefined)?.trustedHosts - if (value === undefined) return [] - const valid = Array.isArray(value) && value.every((entry: unknown) => typeof entry === 'string') - if (!valid) throw new Error('web-startup: the composed connection trustedHosts must be an array of strings') - return value -} - -/** - * Non-internal IPv4 interface addresses of this machine — the IP-literal - * authorities an all-interfaces bind is reachable by on the LAN. - * @returns the addresses in interface order (possibly empty). - */ -function lanIPv4Addresses(): string[] { - return Object.values(networkInterfaces()).flat() - .filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal) - .map(iface => iface.address) -} - -/** - * One LAN-trust resolution for one invocation, sampled exactly once: the - * machine's LAN IP literals when the effective bind is all-interfaces, and the - * `trustedHosts` value built from them plus the explicit extras. The single - * sample is deliberate — display must advertise only addresses the fence was - * configured with, so the `web-runtime` row receives this same snapshot. - * Derived entries are port-less IP literals: DNS rebinding needs an - * attacker-controlled name, so an IP-literal Host is safe on any port, and the - * bound port may be OS-assigned, unknowable before the server binds. - * @param bindHost - the effective webserver bind host (the flag, else the composed row value). - * @param extra - `--trusted-host` values, in argv order. - * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty). - */ -export function resolveLanTrust( - bindHost: string | undefined, - extra: readonly string[], -): { lanAddresses: string[]; trustedHosts: string[] } { - const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [] - return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } } /** The web flag family, as commander parsed it. */ @@ -125,51 +62,29 @@ Examples: } /** - * Turn the parsed flags into the values the web rows read. + * Turn the parsed flags into the value injected rows read. * @param program - the parsed web command. - * @param rows - the waiting rows' composed options, in tree order. - * @param ctx - the startup context used to resolve composed fallbacks before `webStartup` exists. - * @returns the web rows' service value. + * @returns this invocation's immutable Web options. */ -function planWebStartup(program: Command, rows: readonly EntryOptions[], ctx: Context): WebStartupValues { +function planWebStartup(program: Command): WebStartupValues { const options = program.opts<WebOptions>() if (options.port !== undefined && !/^\d+$/.test(options.port)) { program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) } - const row = (id: string): EntryOptions => { - const found = rows.find(candidate => candidate.id === id) - if (found === undefined) throw new Error(`web-startup: the web composition has no waiting ${JSON.stringify(id)} row to configure`) - return found - } - const webserver = row('webserver') - row('web-runtime') - const connection = row('connection') - // Include preserves nested row expressions until their own injections are - // active. Resolve just the composed fields this startup plan needs against - // the pre-service context, where their `ctx.get('webStartup')` fallback wins. - const webserverConfig = interpolate(ctx, webserver.config) as { host?: string } | undefined - const connectionConfig: unknown = interpolate(ctx, connection.config) - const bindHost = options.host ?? webserverConfig?.host - const sampled = resolveLanTrust(bindHost, options.trustedHost ?? []) - // Preserve deployment authorities when invocation-derived LAN literals or - // explicit extras become the runtime value read by the connection row. - const composedTrusted = configuredTrustedHosts(connectionConfig) return { ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, - // mode and lanAddresses describe this invocation, never the deployment, so - // they are resolved on every boot. mode: options.dev === true ? 'development' : 'production', - trustedHosts: [...composedTrusted, ...sampled.trustedHosts], - lanAddresses: sampled.lanAddresses, + trustedHosts: options.trustedHost ?? [], } } /** - * Resolve the web flag family for rows waiting on `webStartup`. - * @param ctx - plugin context carrying the command line and the Loader. - * @returns nothing once the values are provided, or once `--help` requested exit. + * Parse and provide the Web invocation as an ordinary Cordis service. + * @param ctx - plugin context carrying the command line. + * @returns nothing once values are provided, or when the command requested exit. */ export function apply(ctx: Context): void { - runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup) + const values = parseCmdline(ctx, webCommand(), planWebStartup) + if (values !== undefined) ctx.provide(WEB_STARTUP_SERVICE, values) } diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index f58c771aaa..91ec241889 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -1,8 +1,6 @@ /** - * The web app's startup row over a REAL Loader tree: every flag lands in the - * `webStartup` service the web rows read, the bind it reports comes from the - * flag or from what the composition falls back to, `--help` resolves nothing, - * and a rejected argument exits without resolving anything. + * The Web command-line provider over a real Loader tree: its ordinary service + * releases a consumer whose config reads `ctx.webStartup` directly. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -13,21 +11,14 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { apply, WEB_STARTUP_SERVICE, type WebStartupValues } from '../src/startup.ts' -vi.mock('node:os', async importOriginal => ({ - ...await importOriginal<typeof import('node:os')>(), - networkInterfaces: () => ({ - lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }], - en0: [{ family: 'IPv4', internal: false, address: '192.168.1.5' }], - }), -})) - -/** What one boot of the fixture tree observed. */ +/** What one fixture boot observed. */ interface Observed { exits: number[] out: string + readerConfig?: unknown } const disposers: (() => Promise<void>)[] = [] @@ -39,67 +30,48 @@ afterEach(async () => { }) /** - * Mount the real startup row over a stand-in for the `webserver` row whose - * composed bind it reads before the dependent rows activate. + * Mount the real provider and a consumer using injection-ordered config. * @param args - the invocation's inner arguments. - * @param webserverConfig - the composed `webserver` row config, or `null` to omit the row. - * @param trustedHosts - authorities the composed connection row already carries, or `null` when it carries none. - * @returns the resolved service value (absent when the app requested exit) and what the boot observed. + * @returns the service value and observed consumer/process effects. */ -async function bootStartup( - args: string[], - webserverConfig: Record<string, unknown> | null = { host: '127.0.0.1', port: 3080 }, - trustedHosts: unknown = [], -): Promise<{ values: WebStartupValues | undefined; observed: Observed; ctx: Context }> { +async function bootProvider(args: string[]): Promise<{ + values: WebStartupValues | undefined + observed: Observed +}> { const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-')) const observed: Observed = { exits: [], out: '' } - writeFileSync(join(dir, 'row.mjs'), 'export function apply() {}\n') - // The Loader imports a row through Node's own resolver, which cannot resolve - // this workspace's sources; the row delegates to the real plugin the test - // imported through the source-plane path mapping. - writeFileSync(join(dir, 'startup.mjs'), ` + writeFileSync(join(dir, 'reader.mjs'), ` +export function apply(_ctx, config) { globalThis.__webStartupObserved.readerConfig = config } +`) + // Node imports the fixture row outside Vite's source resolver, so delegate + // to the source-plane plugin already imported by this test. + writeFileSync(join(dir, 'provider.mjs'), ` export const name = 'web-startup' export const inject = ['cmdlineArgs'] export const apply = ctx => globalThis.__webStartupApply(ctx) `) - const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href writeFileSync(join(dir, 'cordis.yml'), [ - ...webserverConfig === null ? [] : [ - '- id: webserver', - ` name: ${rowUrl}`, - ` inject: [${WEB_STARTUP_SERVICE}]`, - ' disabled: true', - ' config:', - ...Object.entries(webserverConfig).map(([key, value]) => ` ${key}: !!js ctx.get('${WEB_STARTUP_SERVICE}')?.${key} ?? ${JSON.stringify(value)}`), - ], - '- id: connection', - ` name: ${rowUrl}`, + '- id: reader', + ` name: ${pathToFileURL(join(dir, 'reader.mjs')).href}`, ` inject: [${WEB_STARTUP_SERVICE}]`, - ' disabled: true', - ...trustedHosts === null ? [] : [ - ' config:', - ` trustedHosts: !!js ctx.get('${WEB_STARTUP_SERVICE}')?.trustedHosts ?? ${JSON.stringify(trustedHosts)}`, - ], - // A second reader keeps the composition honest when the webserver row is - // the one under test: the service must still have someone to serve. - '- id: web-runtime', - ` name: ${rowUrl}`, - ` inject: [${WEB_STARTUP_SERVICE}]`, - ' disabled: true', - // The reload chain this bundle ships off, which `--dev` turns on. - '- id: client-hmr', - ` name: ${rowUrl}`, - ` inject: [${WEB_STARTUP_SERVICE}]`, - ' disabled: true', - '- id: web-startup', - ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, - ' inject: [cmdlineArgs]', + ' config:', + " host: !!js ctx.webStartup.host ?? '127.0.0.1'", + ' port: !!js ctx.webStartup.port ?? 3080', + ' mode: !!js ctx.webStartup.mode', + ' trustedHosts: !!js ctx.webStartup.trustedHosts', + '- id: provider', + ` name: ${pathToFileURL(join(dir, 'provider.mjs')).href}`, '', ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } internals.stdout = observing internals.stderr = observing - ;(globalThis as unknown as { __webStartupApply: typeof apply }).__webStartupApply = apply + const globals = globalThis as unknown as { + __webStartupApply: typeof apply + __webStartupObserved: Observed + } + globals.__webStartupApply = apply + globals.__webStartupObserved = observed const ctx = new Context() await ctx.plugin(Loader) @@ -108,89 +80,56 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) await ctx.loader.await() disposers.push(async () => { await ctx.fiber.dispose() }) - return { values: ctx.get(WEB_STARTUP_SERVICE) as WebStartupValues | undefined, observed, ctx } + return { + values: ctx.get(WEB_STARTUP_SERVICE) as WebStartupValues | undefined, + observed, + } } - -describe('web startup', () => { - it('resolves each flag into the value its row reads', async () => { - const { values } = await bootStartup(['--port', '8080']) +describe('web command-line provider', () => { + it('publishes each flag and releases direct service expressions', async () => { + const { values, observed } = await bootProvider([ + '--host', '0.0.0.0', + '--port', '8080', + '--dev', + '--trusted-host', 'lab.internal', 'lab-2.internal', + '--trusted-host', '10.0.0.9', + ]) expect(values).toEqual({ + host: '0.0.0.0', port: 8080, + mode: 'development', + trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'], + }) + expect(observed.readerConfig).toEqual(values) + expect(observed.exits).toEqual([]) + }) + + it('leaves deployment values to each consumer when flags omit them', async () => { + const { values, observed } = await bootProvider([]) + expect(values).toEqual({ mode: 'production', trustedHosts: [] }) + expect(observed.readerConfig).toEqual({ + host: '127.0.0.1', + port: 3080, mode: 'production', trustedHosts: [], - lanAddresses: [], }) }) - it('names no value for a flag the invocation left out, so each row keeps its own', async () => { - const { values } = await bootStartup([]) - expect(values).toEqual({ mode: 'production', trustedHosts: [], lanAddresses: [] }) - expect(values).not.toHaveProperty('host') - expect(values).not.toHaveProperty('port') - }) - - it('adds LAN literals and explicit extras after the composed fence authorities', async () => { - const { values } = await bootStartup( - ['--host', '0.0.0.0', '--trusted-host', 'lab.internal', 'lab-2.internal', '--trusted-host', '10.0.0.9'], - { host: '127.0.0.1', port: 3080 }, - ['profile.internal'], - ) - expect(values?.trustedHosts).toEqual([ - 'profile.internal', '192.168.1.5', 'lab.internal', 'lab-2.internal', '10.0.0.9', - ]) - // Display gets the same single sample the fence was configured with. - expect(values?.lanAddresses).toEqual(['192.168.1.5']) - }) - - it('starts from an empty trust list when the composed connection row names none', async () => { - const { values } = await bootStartup( - ['--trusted-host', 'lab.internal'], - { host: '127.0.0.1', port: 3080 }, - null, - ) - expect(values?.trustedHosts).toEqual(['lab.internal']) - }) - - it.each([ - 'profile.internal', - ['profile.internal', 1], - ])('rejects an invalid composed trust list before transforming it (%j)', async (trustedHosts) => { - await expect(bootStartup([], { host: '127.0.0.1', port: 3080 }, trustedHosts)) - .rejects.toThrow('the composed connection trustedHosts must be an array of strings') - }) - - it('reads the composed bind when no flag names one, so a configured 0.0.0.0 still derives them', async () => { - const { values } = await bootStartup([], { host: '0.0.0.0', port: 3080 }) - expect(values?.lanAddresses).toEqual(['192.168.1.5']) - }) - - it('reports the development mode for --dev, which the web runtime reads', async () => { - const { values } = await bootStartup(['--dev']) - // The runtime row turns the reload chain on after its host dependencies - // activate; this row only reports the mode. - expect(values?.mode).toBe('development') - }) - - it('prints its own help and resolves nothing', async () => { - const { values, observed } = await bootStartup(['--help']) + it('prints its own help and leaves the consumer pending', async () => { + const { values, observed } = await bootProvider(['--help']) expect(observed.out).toContain('dsh --profile web') expect(observed.out).toContain('--trusted-host') expect(values).toBeUndefined() + expect(observed.readerConfig).toBeUndefined() expect(observed.exits).toEqual([0]) }) - it('rejects a non-numeric port before anything binds', async () => { - const { values, observed } = await bootStartup(['--port', 'abc']) + it('rejects a non-numeric port before the consumer activates', async () => { + const { values, observed } = await bootProvider(['--port', 'abc']) expect(observed.out).toContain('--port must be a number') expect(values).toBeUndefined() + expect(observed.readerConfig).toBeUndefined() expect(observed.exits).toEqual([1]) }) - - it('fails the boot when the composition lost the row whose bind it reads', async () => { - // The bundle patch and this startup row must agree on the row set; a - // missing row would otherwise silently drop the flag that targets it. - await expect(bootStartup([], null)) - .rejects.toThrow('the web composition has no waiting "webserver" row to configure') - }) }) diff --git a/packages/bundle/web-app/tests/trusted-hosts.spec.ts b/packages/bundle/web-app/tests/trusted-hosts.spec.ts index 110aaeae61..5972569b0d 100644 --- a/packages/bundle/web-app/tests/trusted-hosts.spec.ts +++ b/packages/bundle/web-app/tests/trusted-hosts.spec.ts @@ -1,7 +1,7 @@ /** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */ import { describe, expect, it, vi } from 'vitest' -import { resolveLanTrust } from '../src/startup.ts' +import { resolveLanTrust } from '../src/index.ts' vi.mock('node:os', () => ({ networkInterfaces: () => ({ @@ -26,8 +26,9 @@ describe('resolveLanTrust', () => { expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) }) - it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => { + it('derives nothing for a loopback bind — extras alone stand, no LAN URL to print', () => { expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] }) - expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) + expect(resolveLanTrust('127.0.0.1', ['lab.internal'])) + .toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) }) }) diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index df34637cab..f8c5079f17 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -2,7 +2,7 @@ * Web runtime glue behavior: dist resolution through the bundle's own hook, * the frontend-static child claiming the fallback seat, the web-surface * prompt section and bash runtime variables, and URL-line printing with the - * app startup row's LAN snapshot. + * runtime's bind-dependent LAN snapshot. */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -14,6 +14,14 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver' import { apply, Config, internals } from '../src/index.ts' +vi.mock('node:os', async importOriginal => ({ + ...await importOriginal<typeof import('node:os')>(), + networkInterfaces: () => ({ + lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }], + en0: [{ family: 'IPv4', internal: false, address: '192.168.1.5' }], + }), +})) + let dist: string | undefined afterEach(() => { @@ -36,9 +44,10 @@ function stageDist(): string { } /** A fake httpServer capturing the fallback seat and index taps. */ -function fakeHttpServer(): { server: HttpServerService; seat: () => unknown } { +function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server: HttpServerService; seat: () => unknown } { let fallback: unknown const server = { + host, port: 4567, registerFallback: (handler: unknown) => { fallback = handler @@ -72,7 +81,7 @@ describe('web-app runtime glue', () => { it('mounts dist serving, prompt section, bash variables, and prints the URL with the LAN snapshot', async () => { stageDist() const ctx = new Context() - const { server, seat } = fakeHttpServer() + const { server, seat } = fakeHttpServer('0.0.0.0') ctx.provide('httpServer', server) const contributions: BashContribution[] = [] ctx.provide('bashEnv', { @@ -83,14 +92,17 @@ describe('web-app runtime glue', () => { } as never) const enabledRows = provideHmrRow(ctx) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] })) + await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] })) await ctx.plugin(SystemPrompt, { persona: '' }) // Settle the injected registrations. await new Promise(resolve => setTimeout(resolve, 0)) expect(seat()).toBeDefined() // frontend-static claimed the fallback expect(enabledRows).toEqual(['client-hmr']) - expect(ctx.get('webClientRoster')).toBe(true) + expect(ctx.get('webRuntime')).toEqual({ + lanAddresses: ['192.168.1.5'], + trustedHosts: ['192.168.1.5', 'lab.internal'], + }) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)') const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') @@ -107,7 +119,7 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() @@ -128,7 +140,7 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) - await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) const assembly = await ctx.systemPrompt.assemble() @@ -143,7 +155,7 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await ctx.fiber.dispose() @@ -159,7 +171,7 @@ describe('web-app runtime glue', () => { const settlement = new Promise<void>((resolve) => { release = resolve }) provideHmrRow(settled, () => settlement) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() release!() @@ -173,7 +185,7 @@ describe('web-app runtime glue', () => { const failed = new Context() failed.provide('httpServer', fakeHttpServer().server) provideHmrRow(failed, async () => { throw new Error('boot failed') }) - await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() await failed.fiber.dispose() @@ -189,7 +201,7 @@ describe('web-app runtime glue', () => { let releaseTorn: () => void const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve }) provideHmrRow(torn, () => tornSettlement) - await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await child.dispose() // the httpServer service goes away releaseTorn!() await new Promise(resolve => setTimeout(resolve, 0)) @@ -205,7 +217,7 @@ describe('web-app runtime glue', () => { const { server } = fakeHttpServer() Object.defineProperty(server, 'port', { get: () => undefined }) ctx.provide('httpServer', server) - await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99801b0001..9e74ebfd80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1185,10 +1185,6 @@ importers: version: 4.0.9 packages/boot/cmdline: - dependencies: - commander: - specifier: ^15.0.0 - version: 15.0.0 devDependencies: '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 @@ -1202,6 +1198,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + commander: + specifier: ^15.0.0 + version: 15.0.0 packages/bundle/base: dependencies: From 5dcee005ddc31f53f79a21dbe2a2864fcd2f3f23 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Mon, 10 Aug 2026 21:52:06 +0800 Subject: [PATCH 169/229] test(cli): guard the headless composition boundary --- apps/cli/tests/built-bin.e2e.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index b9e99604c1..015a729d5a 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -697,6 +697,19 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") }, 30_000) + it('prints the headless profile without Host or browser layers', async () => { + const { stdout, code, stderr } = await runBuiltBin( + ['--profile', 'headless', '--dump-default-config'], + { DSH_HOME: home }, + ) + expect(code).toBe(0) + expect(stderr).toBe('') + expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'") + expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/) + expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'") + expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/) + }, 30_000) + it('composes the profile user layer and a --patch overlay in order', async () => { // Auto-init the web profile first, then write its user layer. const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home }) From 45b300dc58ab9428715ececcb38ca0d9beb00587 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Mon, 10 Aug 2026 22:04:48 +0800 Subject: [PATCH 170/229] docs(notes): archive superseded dsh run decision --- .../feature/2026-08-08-dsh-run-headless-command.i18n.yaml | 6 ++++++ .../feature/2026-08-08-dsh-run-headless-command.md | 1 + .../feature/2026-08-08-dsh-run-headless-command.zh.md | 1 + .agents/notes/archived/manifest.json | 3 +++ .../2026-08-09-headless-direct-core-entry-point.i18n.yaml | 4 ++-- .../2026-08-09-headless-direct-core-entry-point.md | 2 +- .../2026-08-09-headless-direct-core-entry-point.zh.md | 2 +- .../feature/2026-08-08-dsh-run-headless-command.i18n.yaml | 6 ------ 8 files changed, 15 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-08-08-dsh-run-headless-command.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-08-08-dsh-run-headless-command.zh.md (99%) delete mode 100644 .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml diff --git a/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml new file mode 100644 index 0000000000..d07841d540 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md +2026-08-08-dsh-run-headless-command.md: ce9cff965192357022c49655983fe6ff8d554b9f +2026-08-08-dsh-run-headless-command.zh.md: 0484c069ed6365235e4616542a4fb3d5ceb2d880 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md rename to .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md index 779e568790..ce9cff9651 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md +++ b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md @@ -1,6 +1,7 @@ # Agent Note: `dsh run` owns one-shot headless execution Status: implemented +Archived: 2026-08-10 English | [中文](2026-08-08-dsh-run-headless-command.zh.md) diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md rename to .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md index 5a21033e92..0484c069ed 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md +++ b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -1,6 +1,7 @@ # Agent Note: `dsh run` 负责一次性 headless 执行 Status: implemented +Archived: 2026-08-10 [English](2026-08-08-dsh-run-headless-command.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 638d87373d..d3d1795489 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -253,6 +253,9 @@ "feature/2026-07-31-web-cards-toolrow.i18n.yaml": "sha256:f9a6ab72a77934cdcc02167c7313f08d7e9925362017b34bed7ad56c8c70fbaa", "feature/2026-07-31-web-cards-toolrow.md": "sha256:5058f7cec4497d1cb0a5c8e77b88fddacac6eead034f3edec88e8514919b8a3e", "feature/2026-07-31-web-cards-toolrow.zh.md": "sha256:ba84ef2e1be61211ab5ba6950b78ede3d3a979f252bc068d3e04e2c025f7bc03", + "feature/2026-08-08-dsh-run-headless-command.i18n.yaml": "sha256:1c2b4c5b61b9263b6267275d6fc69faeaad3cc887f0728a7ed4172d817af812b", + "feature/2026-08-08-dsh-run-headless-command.md": "sha256:7695fe7fd322377d5986f14e35f13337f4cd376405c758218a81230f6d182d1c", + "feature/2026-08-08-dsh-run-headless-command.zh.md": "sha256:113c14a36c64d2facc8ae46f37c7aa76359d8cacb9c18fcba26a723f15d036fb", "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d", "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e", "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b", diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml index 989c195965..a5e5d22982 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md -2026-08-09-headless-direct-core-entry-point.md: e411214a666787ff62626728c4e6887bfc3ec311 -2026-08-09-headless-direct-core-entry-point.zh.md: d17aab2352c9f55b58e52856ebb83cd6351afb10 +2026-08-09-headless-direct-core-entry-point.md: b705df2e6d88e096ee3ba50a6156b815dbd98b98 +2026-08-09-headless-direct-core-entry-point.zh.md: 439f4c21a2ce1741e8d483bc307508550da1bec7 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md index e411214a66..b705df2e6d 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md @@ -20,7 +20,7 @@ The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The headl `loadProfile` recognizes the exact installation-owned headless tuple (`dsh-base`, `dsh-web-app`, `dsh-headless`) and normalizes it to the shipped headless template while preserving every other manifest field. Extra, missing, or reordered bundle lists are user-owned and remain untouched. -This note owns the headless transport and completion contracts. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. +This note owns the headless transport and completion contracts. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../../archived/feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. ## Verification diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md index d17aab2352..439f4c21a2 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md @@ -20,7 +20,7 @@ Status: implemented `loadProfile` 识别安装过程拥有的精确 headless 元组(`dsh-base`、`dsh-web-app`、`dsh-headless`),将其规范化为随附的 headless 模板,并保留 manifest(元数据清单)的其他所有字段。带额外项、缺少项或顺序不同的组合包列表归用户所有,保持不变。 -本 Agent Note 负责 headless 的传输与完成约定。[应用持有自己的命令行](2026-08-06-app-owned-command-line.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.md)负责共享 Agent 默认值的持久化。 +本 Agent Note 负责 headless 的传输与完成约定。[应用持有自己的命令行](2026-08-06-app-owned-command-line.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../../archived/feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.md)负责共享 Agent 默认值的持久化。 ## 验证 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml deleted file mode 100644 index 7b9076e9b4..0000000000 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md -2026-08-08-dsh-run-headless-command.md: 779e568790a58899488ea87292c1bc2db329617f -2026-08-08-dsh-run-headless-command.zh.md: 5a21033e921cb181aa6987259d37b4bc5004e2d9 From ca391942215ab298f0b593d307e8f54f6c7ef6ef Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Mon, 10 Aug 2026 22:18:33 +0800 Subject: [PATCH 171/229] docs(web): align client roster terminology --- packages/bundle/web-app/cordis.patch.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 199c46a33b..fb41923d71 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -123,7 +123,7 @@ inject: [webStartup] disabled: true - # ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── + # ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ── # Dual-face: this waits for the runtime row to decide whether HMR belongs # in the first graph. The node half then scans this tree, composes From dab601e1236afd788b7e54103dfa6f2f9d569fd4 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Mon, 10 Aug 2026 22:34:07 +0800 Subject: [PATCH 172/229] fix(vendor): align command providers with Cordis rescope --- packages/boot/cmdline/src/index.ts | 6 +++--- packages/boot/cmdline/src/invariant.ts | 2 +- packages/boot/cmdline/tests/cmdline.spec.ts | 8 ++++---- packages/bundle/headless/src/startup.ts | 2 +- packages/bundle/headless/tests/startup.spec.ts | 6 +++--- packages/bundle/web-app/src/startup.ts | 2 +- packages/bundle/web-app/tests/startup.spec.ts | 6 +++--- scripts/rescope-vendor.ts | 4 ++-- vendor/README.md | 6 +++--- 9 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index 1236b32cb5..fb502cb0e2 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -16,9 +16,9 @@ */ import type { Command } from 'commander' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' // Empty type import carries the Loader Context merge used by enableRow. -import type {} from '@cordisjs/plugin-loader' +import type {} from '@deepseek-ai/cordis-plugin-loader' /** * The invocation's inner arguments: everything after the launcher's own flags, @@ -42,7 +42,7 @@ export interface AppExit { (code: number): void } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The invocation's inner arguments; provided by a launcher before the tree mounts. */ cmdlineArgs?: CmdlineArgs diff --git a/packages/boot/cmdline/src/invariant.ts b/packages/boot/cmdline/src/invariant.ts index cab932a8e6..b18094a1f4 100644 --- a/packages/boot/cmdline/src/invariant.ts +++ b/packages/boot/cmdline/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-cmdline/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-cmdline' diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index 9faf6ed7d5..bc9b63c9aa 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -9,10 +9,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { Command } from 'commander' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' -import type { PatchOptions } from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterEach, describe, expect, it } from 'vitest' import { enableRow, internals, parseCmdline, provideCmdline, type CmdlinePlan, diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index 7999bb09d2..bfb4d44e51 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -6,7 +6,7 @@ */ import { Command } from 'commander' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { parseCmdline } from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index dce5387b84..07c200202e 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -8,9 +8,9 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' import { afterEach, describe, expect, it } from 'vitest' import { apply, HEADLESS_STARTUP_SERVICE, type HeadlessStartupValues } from '../src/startup.ts' diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index 78d24f553a..040fe843c0 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -7,7 +7,7 @@ */ import { Command } from 'commander' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { parseCmdline } from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index 91ec241889..5108d04232 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -7,9 +7,9 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' import { afterEach, describe, expect, it } from 'vitest' import { apply, WEB_STARTUP_SERVICE, type WebStartupValues } from '../src/startup.ts' diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index e083d375e3..bca3183795 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -126,7 +126,7 @@ const POSTCONDITIONS: readonly PostCondition[] = [ { file: 'packages/boot/app-boot/tsdown.config.ts', text: '[\'@deepseek-ai/cordis-plugin-include\']', count: 1 }, { file: 'tsconfig.base.json', text: '"@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"]', count: 1 }, // One insertion, once: a duplicated log entry is what a non-idempotent apply produced. - { file: 'vendor/README.md', text: '15. **`@deepseek-ai` rescope**', count: 1 }, + { file: 'vendor/README.md', text: '17. **`@deepseek-ai` rescope**', count: 1 }, { file: 'knip.json', text: '@cordisjs', count: 0 }, { file: 'pnpm-workspace.yaml', text: 'cordis@4.0.0-rc.7', count: 0 }, // The preset ids in this table are product data, not package names. @@ -319,7 +319,7 @@ const EXACT_EDITS: readonly ExactEdit[] = [ id: 'vendor-readme-local-modification-log', file: 'vendor/README.md', find: '\n## Sync procedure', - replace: '15. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).\n\n## Sync procedure', + replace: '17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).\n\n## Sync procedure', expect: 1, }, { diff --git a/vendor/README.md b/vendor/README.md index 87e65ed07f..e83f9b4140 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -44,9 +44,9 @@ Keep this log exhaustive — every divergence from upstream must be listed. 12. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. 13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. 14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. -15. **`@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). -16. **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`. -17. **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`. +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). ## Sync procedure 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 173/229] 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 cac8e1c53deb24bc0123463391046b85811aef2b Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 23:59:53 +0800 Subject: [PATCH 174/229] 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 ? ( <span className={css.introText}> - {Array.from(label).map((character, index) => ( + {characters.map((character, index) => ( <span key={index} className={css.introChar} - style={{ animationDelay: `${INTRO_TEXT_DELAY_MS + index * INTRO_CHAR_STAGGER_MS}ms` }} + style={{ animationDelay: `${INTRO_TEXT_DELAY_MS + index * stagger}ms` }} > {character} </span> 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<HTMLElement>('[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 175/229] =?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 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 176/229] 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) 中的版本方案与发布集边界两部分:那篇的 `<base>-<时间戳>-<短 SHA>` 预发布版本与 `dev-<base>` dist-tag 不再采用,vendor 也不再排除在发布集之外。两篇一致的部分保留:pack 与 publish 分离、publish 只消费已验证的 tarball、payload 与安装后探针作为发布门。 + +## 考虑过的替代方案 + +**`<base>-<时间戳>-<短 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 177/229] 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<Record<string, unknown>> +} + +/** + * Read and parse a JSON file. + * @param path - absolute file path. + * @returns The parsed object. + */ +function readManifest(path: string): Record<string, unknown> { + 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<string, unknown> +} + +/** + * 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<string, unknown>, 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<string>() + 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<string>() + const visiting = new Set<string>() + + 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<string, ReleaseMember>): 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<version>`. + */ + 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-<unscoped name>-v<version>`. + */ + 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 <dsh|vendor> [--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<string, unknown> + 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-<base64>` 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 <dsh|vendor> --from <packed directory>') + } + + 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 <dsh|vendor>') + + 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 178/229] 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 179/229] 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<B> 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<Record<string, readonly string[]>> = '@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<Record<string, readonly string[]>> = { @@ -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 180/229] 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<version>` | `release.yml` (new) | +| vendored framework | the nine `vendor/*` packages | each package on its own version line | `vendor-<package>-v<version>` (one per package) | `release-vendor.yml` (new) | +| native | `native/landlock-run/packages/*` | its own `0.0.x` | `landlock-run-v<version>` | `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-<package>-v*` tag and runs `git diff <that tag>..HEAD -- vendor/<directory>`; 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 <directory> pack --pack-destination <one directory>` 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 `<base>-<timestamp>-<short SHA>` prerelease versions and `dev-<base>` 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 `<base>-<timestamp>-<short SHA>` 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 <version>` 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-<package>-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) 中的版本方案与发布集边界两部分:那篇的 `<base>-<时间戳>-<短 SHA>` 预发布版本与 `dev-<base>` dist-tag 不再采用,vendor 也不再排除在发布集之外。两篇一致的部分保留:pack 与 publish 分离、publish 只消费已验证的 tarball、payload 与安装后探针作为发布门。 +本 Note 取代 [以产物为先的 NPM 基线发布](2026-08-04-artifact-first-npm-baseline-publication.md) 中的版本方案与发布集边界两部分:那篇的 `<base>-<时间戳>-<短 SHA>` 预发布版本与 `dev-<base>` 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 181/229] 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 182/229] 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<string, string> devDependencies?: Record<string, string> + dependencies?: Record<string, string> + optionalDependencies?: Record<string, string> } /** 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 <shigma10826@gmail.com>", "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 <shigma10826@gmail.com>", "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 <shigma10826@gmail.com>", "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 <shigma10826@gmail.com>", "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 <shigma10826@gmail.com>", "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 <shigma10826@gmail.com>", "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 183/229] 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<string, unknown>, 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 <dsh|vendor> --from <packed directory>') + } + + 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 184/229] 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 185/229] 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-<package>-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-<package>-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 <major|minor|patch|x.y.z>, 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 <dsh|vendor> [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 <major|minor|patch|x.y.z>') + 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} <merge commit> && 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 186/229] 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 187/229] 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<string, unknown> - 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<string, unknown> + 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<string, { url: string; version: string }> { + const dependencies = new Map<string, { url: string; version: string }>() + 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 <dsh|vendor> --from <packed directory>') + if (values.family === undefined || values.from === undefined || values.from.length === 0) { + throw new Error('usage: verify-packed-install.ts --family <dsh|vendor> --from <packed directory> [--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 188/229] 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 189/229] 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<version>` | `release.yml` | +| vendored framework | the nine `vendor/*` packages | each package on its own version line | `vendor-<package>-v<version>` (one per package) | `release-vendor.yml` | +| native | `native/landlock-run/packages/*` | its own `0.0.x` | `landlock-run-v<version>` | `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-<package>-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 `<base>-<timestamp>-<short SHA>` prerelease versions and `dev-<base>` 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 `<base>-<timestamp>-<short SHA>` 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) 中的版本方案与发布集边界:那篇的 `<base>-<时间戳>-<短 SHA>` 预发布版本与 `dev-<base>` dist-tag 不再采用,vendor 也不排除在发布集之外。两篇一致的部分保留:pack 与 publish 分离、publish 只消费已验证的 tarball、payload 与安装后探针作为发布门。 + +## 曾考虑的替代方案 + +**`<base>-<时间戳>-<短 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<version>` | `release.yml` (new) | -| vendored framework | the nine `vendor/*` packages | each package on its own version line | `vendor-<package>-v<version>` (one per package) | `release-vendor.yml` (new) | -| native | `native/landlock-run/packages/*` | its own `0.0.x` | `landlock-run-v<version>` | `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-<package>-v*` tag and runs `git diff <that tag>..HEAD -- vendor/<directory>`; 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 <directory> pack --pack-destination <one directory>` 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 `<base>-<timestamp>-<short SHA>` prerelease versions and `dev-<base>` 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 `<base>-<timestamp>-<short SHA>` 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 <version>` 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-<package>-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) 中的版本方案与发布集边界两部分:那篇的 `<base>-<时间戳>-<短 SHA>` 预发布版本与 `dev-<base>` dist-tag 不再采用,vendor 也不再排除在发布集之外。两篇一致的部分保留:pack 与 publish 分离、publish 只消费已验证的 tarball、payload 与安装后探针作为发布门。 - -## 考虑过的替代方案 - -**`<base>-<时间戳>-<短 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-<package>-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-<package>-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<string, unknown>).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 <major|minor|patch|x.y.z>') - 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} <merge commit> && git push origin ${tag}`) + for (const tag of [...new Set(planned.map(entry => entry.tag).filter(tag => tag !== undefined))]) { + console.log(` git tag ${tag} <merge commit> && 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<string, unknown> = {}): 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<version>`. + * 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-<unscoped name>-v<version>`. + * @returns `vendor-<unscoped name>-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 <shigma10826@gmail.com>", "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 190/229] 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 191/229] 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<string, { url: string; version: string }> { const dependencies = new Map<string, { url: string; version: string }>() 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 192/229] 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-<package>-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 <dsh|vendor> [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 <major|minor|patch|x.y.z>') + 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 193/229] 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 194/229] 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 195/229] 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<B> 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 196/229] 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 extends keyof ConversationViewSnapshotMap & string>( + 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<State = unknown> { 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<State = unknown> { 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<State>, - target: string, - ): ConversationViewNode | null + buildViewNode?(context: ConversationNodeContext<State>): 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<Co * @returns idempotent disposer. */ register(definition: ConversationNodeDefinition): () => void { + assertDefinitionTarget(definition) return this.registerDefinition( definition.kind, definition, @@ -31,6 +32,9 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry<Co * @returns idempotent disposer. */ registerFallback(definition: ConversationNodeDefinition): () => 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<Co fallbackEntry(): ConversationNodeDefinition | undefined { return this.fallback } - +} + +function assertDefinitionTarget(definition: ConversationNodeDefinition): void { + if ((definition.target === undefined) !== (definition.buildViewNode === undefined)) { + throw new Error( + `conversation Definition "${definition.kind}" must declare target and buildViewNode together`, + ) + } } diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 7dc604ff72..49f5491764 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -6,7 +6,6 @@ import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek- import { SlotsService } from './slots.ts' import { SessionsService } from './sessions/service.ts' import type { SessionListState } from './sessions/service.ts' -import { SessionHistoryService } from './session-history/service.ts' import { WorkspacesService } from './workspaces/service.ts' import type { ConversationSnapshot } from './sessions/conversation.ts' import type { UseProjection } from './sessions/projection-store.ts' @@ -28,12 +27,12 @@ export type { ConversationLocation, ConversationMatch, ConversationMatchResult, ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext, ConversationPublication, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewBuilder, - ConversationViewDefinition, ConversationViewNode, StepLocation, TurnLocation, + ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap, + ConversationViewSnapshotStore, StepLocation, TurnLocation, } from './contract/conversation.ts' export type { ConversationRuntime } from './sessions/conversation-assembler.ts' export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' -export { SessionHistoryService } from './session-history/service.ts' export { indexSubagentDescendants } from './sessions/subagent-lineage.ts' export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts' // The provide channel is shared with the client test runtime (one @@ -48,9 +47,6 @@ export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './ export { resolveWorkspacePath } from './workspaces/path.ts' export type { Session } from './sessions/session.ts' export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' -export type { - ISessionHistory, SessionHistoryFace, SessionHistorySnapshot, -} from './contract/session-history.ts' export type { AgentContext, ISessions } from './contract/sessions.ts' export type { IWorkspaces } from './contract/workspaces.ts' export type { @@ -76,7 +72,9 @@ export type { LegacyConversationSlice, PartialAssistant, RunningToolCall, SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' -export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './sessions/conversation.ts' +export { + EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, toAssistantBlock, toAssistantBlocks, +} from './sessions/conversation.ts' export { emptyAssistantBlock } from './sessions/partial.ts' export { isTokenDelta } from './sessions/assistant-timing.ts' export { contextForm, contextProvenance } from './sessions/context-provenance.ts' @@ -90,8 +88,6 @@ export type { export type { ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, } from './sessions/request-inspection.ts' -export type { ConversationHistoryProjection } from './session-history/history-fold.ts' -export type { SessionHistoryInspection } from './sessions/history.ts' export { PendingWait } from './sessions/pending.ts' export type { PendingInteraction, PendingInteractionStatus, PendingKind, PendingPayloads, @@ -211,8 +207,6 @@ declare module '@deepseek-ai/cordis' { conversationViews: import('./conversation/view-registry.ts').ConversationViewRegistry /** The outward face only; the concrete service stays inside the runtime. */ sessions: import('./contract/sessions.ts').ISessions - /** Read-only history sources isolated from Chat sessions and workspace state. */ - sessionHistory: import('./contract/session-history.ts').ISessionHistory /** The outward face only; the concrete service stays inside the runtime. */ workspaces: import('./contract/workspaces.ts').IWorkspaces } @@ -235,7 +229,6 @@ export function apply(ctx: Context): void { ctx.typert.contexts.registerClient('agent', { identity: candidate => 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<string, InternalContext>() private readonly contextsByKind = new Map<string, InternalContext[]>() private readonly contextsBySeq = new Map<number, Set<InternalContext>>() @@ -266,11 +267,11 @@ export class ConversationNodeAssembler { const allByTarget = new Map<string, ConversationViewNode[]>() 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 extends keyof ConversationViewSnapshotMap & string>( + 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<string>() 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<null> = { 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<ConversationNodeDefinition['buildViewNode']>[0], data: unknown): ConversationViewNode { +function node( + context: Parameters<NonNullable<ConversationNodeDefinition['buildViewNode']>>[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<null> { 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<ChatConversationViewNo const TEST_EVENT_DEFINITION: ConversationNodeDefinition<TestEventState> = { 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<AssistantState>): Ass /** Per-step Assistant streaming/final/interruption Definition. */ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = { 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<AssistantState> = { 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<State extends CompactionEvidence>( /** Slash-command lifecycle, including integrated manual compaction, Definition. */ export const commandDefinition: ConversationNodeDefinition<CommandState> = { 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<CommandState> = { } 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<CompactionState>): Compa /** Automatic compaction lifecycle and landed checkpoint Definition. */ export const compactionDefinition: ConversationNodeDefinition<CompactionState> = { kind: 'compaction', + target: 'chat', match: (event) => { const checkpoint = compactSource(event) if (checkpoint !== undefined && checkpoint.sourceCommandId === undefined) { @@ -47,8 +48,7 @@ export const compactionDefinition: ConversationNodeDefinition<CompactionState> = }, 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<UnknownSurfaceNode> = { kind: 'unknown-surface', + target: 'chat', match: event => isAppendSurfaceEvent(event) ? { id: String(event.seq), role: 'start' } : null, @@ -26,7 +27,7 @@ export const unknownFallbackDefinition: ConversationNodeDefinition<UnknownSurfac data: match.event.data, }), update: context => 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<InboxS }, update: context => 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<ConversationNodeDefinition['ma /** User, steering, and injected-context message classification Definition. */ export const messageDefinition: ConversationNodeDefinition<MessageNode> = { kind: 'input-message', + target: 'chat', match: event => event.type === 'user/message' && isAppendSurfaceEvent(event) && !isCompactionCheckpoint(event) @@ -68,8 +69,8 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = { } }, 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<RetryState> = { 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<RetryState> = { 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>): ToolState | /** Root Tool lifecycle and nested Code Dispatch Definition. */ export const toolDefinition: ConversationNodeDefinition<ToolState> = { 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<ToolState> = { } 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<TurnErrorState>): TurnEr /** Terminal turn failure Definition, suppressed when the turn owns a retry chain. */ export const turnErrorDefinition: ConversationNodeDefinition<TurnErrorState> = { 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<TurnErrorState> = { ? { ...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<TurnTailState>): TurnTailChat /** Completed-turn footer Definition independent of any Assistant row. */ export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = { 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<TurnTailState> = { 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> = {}): 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<ConversationSnapshot>({ - 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<ConversationSnapshot>({ - 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> = {}): 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<DeliverablesState> = { 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 197/229] 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<number> = 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<boolean> } - loadHistoryTail: (signal: AbortSignal) => Promise<void> - loadOlderHistory: (signal: AbortSignal) => Promise<boolean> + loadOlder: () => Promise<boolean> 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<TrajectoryViewInjected>) { const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(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<AbortController | null>(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 ( <div className={css.root} data-conversation-composer-overlay=""> 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<AssistantState>, +): { 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>): 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<AssistantState>, +): 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<RequestView, { purpose: 'assistant' }> | 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<AssistantState> = { + 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<TurnEndState> = { + 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<ConversationNodeDefinition['match']>[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<ConversationNodeDefinition['match']>[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<RequestView, { purpose: 'compaction' }> | 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<CompactionState> = { + 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<SessionEndState> = { + 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<RequestView, { purpose: 'assistant' }> + } + | { + readonly kind: 'tool' + readonly root: ToolCallBlock + } + | { + readonly kind: 'request-header' + readonly header: TrajectoryRequestHeaderState + } + | { + readonly kind: 'compaction' + readonly request: Extract<RequestView, { purpose: 'compaction' }> + } + | { + 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<string, ConversationPromptSnapshot['tools'][number]> + 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<string> +} + +type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode + +function applySplice( + previous: ConversationPreviousContext<InboxState> | 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<InboxState> = { + 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<InboxState>('trajectory-inbox-next-step'), + match.event.data, + ) + }, + update: context => context.state, + publication: () => 'none', +} + +const trajectoryMessageDefinition: ConversationNodeDefinition<MessageNode> = { + 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<InboxState>('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<TrajectoryRequestHeaderState> = { + 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<TrajectoryRequestHeaderState>('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<RequestView, { purpose: 'assistant' }>, + 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<RequestView, { purpose: 'assistant' }>, + header: TrajectoryRequestHeaderState | undefined, +): Extract<RequestView, { purpose: 'assistant' }> { + 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<string, ConversationPromptSnapshot['tools'][number]>, +): 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<string, TrajectoryConversationViewNode>() + readonly empty = EMPTY_TRAJECTORY_SNAPSHOT + + replace(input: { + readonly nodes: readonly TrajectoryConversationViewNode[] + }): TrajectorySnapshot { + this.nodes.clear() + for (const node of input.nodes) this.nodes.set(node.key, node) + 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<string, ConversationPromptSnapshot['tools'][number]>() + 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<string, ToolCallBlock> + readonly children: ReadonlyMap<string, readonly string[]> + readonly parents: ReadonlyMap<string, string> +} + +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<ToolState>, +): { 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<string>(), + 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>): 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<ToolState> = { + 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<SlotsService, SnapshotStore<ConversationSnapshot>>() const tConversation: ConversationSessionHeaderProps['t'] = key => (conversationZh as Record<string, string>)[key] ?? key @@ -67,37 +72,55 @@ const NODES = [ function historySnapshot( nodes: ConversationSnapshot['nodes'], - inspection: Partial<SessionHistoryInspection> = {}, -): SessionHistorySnapshot { + inspection: Partial<TrajectorySnapshot> = {}, +): 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<typeof TrajectoryView>, - '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<ConversationSnapshot> } + const store = createSnapshotStore(historySnapshot(nodes)) + return { store, useSession: bindSnapshotSelector(store) as UseSession<ConversationSnapshot> } } /** 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(() => <div data-testid="chat-body" />) 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<ConversationSnapshot> + const sessionSnapshot = sessionSnapshots.get(slots) ?? createSnapshotStore(historySnapshot(nodes)) + const useSession = bindSnapshotSelector(sessionSnapshot) as UseSession<ConversationSnapshot> 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', () => { <TrajectoryView {...standaloneProps([])} {...standaloneDuration()} - useHistory={bindSnapshotSelector(store)} - loadHistoryTail={vi.fn(() => Promise.resolve())} - loadOlderHistory={vi.fn(() => Promise.resolve(false))} + useSession={bindSnapshotSelector(store)} + loadOlder={vi.fn(() => Promise.resolve(false))} />, ) @@ -1196,9 +1206,8 @@ describe('TrajectoryView branches', () => { <TrajectoryView {...standaloneProps([])} {...standaloneDuration()} - useHistory={bindSnapshotSelector(store)} - loadHistoryTail={vi.fn(() => 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', () => { <TrajectoryView {...standaloneProps([])} {...standaloneDuration()} - useHistory={bindSnapshotSelector(store)} - loadHistoryTail={vi.fn(() => 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', () => { <TrajectoryView {...standaloneProps([])} {...standaloneDuration()} - useHistory={bindSnapshotSelector(store)} - loadHistoryTail={vi.fn(() => 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 198/229] 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<SessionHistorySnapshot> { - 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<void> - /** - * 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<boolean> -} - -/** 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<number, number>() - 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<string, CallIndexEntry>, - 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<string, RunningToolCall>() - 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<number>() - 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<string, CallIndexEntry>() - const resultViews = new Map<number, ToolResultView>() - const assistantSteps = new Map<string, AssistantStepMetadata>() - const assistantTimings = new Map<number, AssistantTiming>() - const assistantRequestConfigs = new Map<number, AssistantRequestConfig>() - const promptsByContext = new Map<number, ConversationPromptSnapshot>() - 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<number, ConversationNode>() - 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<SessionId, SessionHistorySource>() - - /** - * @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<MuxFrame>): 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<HostFrame>): 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<AbortSignal>() - private openPromise: Promise<void> | null = null - private olderPromise: Promise<void> | 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<void> { - 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<boolean> { - 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<void> { - 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<void> { - 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<never>(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<void> { - 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<void> { - 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<string, ToolSchema> - 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<string>() - for (const { event } of entries) { - if (event.type === 'assistant/message') { - completedSteps.add(assistantStepKey(event.data.turn, event.data.step)) - } - } - - const firstTokenSteps = new Set<string>() - 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<typeof projectConversationHistory> | undefined - let requests: ReturnType<typeof inspectRequests> | 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<string, ToolSchema> } - -/** - * 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<string, ToolSchema> { - let active = new Map<string, ToolSchema>() - const calls = new Map<string, ToolSchema>() - 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<string, number>() - const lastStepByTurn = new Map<number, string>() - let activeStep: string | undefined - let activePrompt: ConversationPromptSnapshot | undefined - let activeCompaction: number | undefined - - const updateAssistant = ( - index: number | undefined, - change: Partial<Omit<AssistantRequestView, 'purpose'>>, - ): void => { - if (index === undefined) return - const request = requests[index] - if (request?.purpose === 'assistant') requests[index] = { ...request, ...change } - } - const updateCompaction = ( - index: number | undefined, - change: Partial<Omit<CompactionRequestView, 'purpose'>>, - ): 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<string, unknown>): 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: '<available_skills>…</available_skills>' }], - // 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<Awaited<ReturnType<FakeApiClient['onHistory']>>>() - const olderStarted = deferred<undefined>() - 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<string, string> = { 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 199/229] 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<Target extends keyof ConversationViewSnapshotMap & string>( + get<Target extends Extract<keyof ConversationViewSnapshotMap, string>>( 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<Target extends keyof ConversationViewSnapshotMap & string>( + get<Target extends Extract<keyof ConversationViewSnapshotMap, string>>( 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<DeliverablesState> = { 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<DeliverablesStat key: 'deliverables', value: { produced: context.state.produced }, }, - buildViewNode: () => 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<TurnEndState> = { 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<SessionEndState buildViewNode: context => 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<MessageNode> = { ?.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<MessageNode> = { : 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<TrajectoryRe buildViewNode: context => 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<string, ConversationPromptSnapshot['tools'][number]>, ): 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<ToolState> = { }, } -/** 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<ConversationSnapshot> } + 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<ConversationSnapshot> + 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 200/229] 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<RequestView, { purpose: 'assistant' }>, header: TrajectoryRequestHeaderState | undefined, + includeChange: boolean, ): Extract<RequestView, { purpose: 'assistant' }> { 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<string, ConversationPromptSnapshot['tools'][number]>() + const consumedPromptChanges = new Set<number>() 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<RequestView, { purpose: 'assistant' }> { + 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 201/229] 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<RequestView, { purpose: 'assistant' }> +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<RequestView, { purpose: 'assistant' }>, - headers: readonly TrajectoryRequestHeaderState[], + request: AssistantRequest, + headersByStep: ReadonlyMap<string, TrajectoryRequestHeaderState>, + 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<RequestView, { purpose: 'assistant' }>, + request: AssistantRequest, header: TrajectoryRequestHeaderState | undefined, includeChange: boolean, -): Extract<RequestView, { purpose: 'assistant' }> { +): AssistantRequest { return header === undefined ? request : { @@ -67,26 +69,39 @@ function withRequestConfig( function captureSchemas( block: ToolCallBlock, - tools: readonly ConversationPromptSnapshot['tools'][number][], - output: Map<string, ConversationPromptSnapshot['tools'][number]>, + toolsByName: ReadonlyMap<string, ToolSchema>, + output: Map<string, ToolSchema>, ): 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<string, ToolSchema> { + 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<number, number>() + 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<string, TrajectoryConversationViewNode>() + private readonly positions = new Map<string, number>() + 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<string, TrajectoryRequestHeaderState>() + 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<string, ConversationPromptSnapshot['tools'][number]>() + const callSchemas = new Map<string, ToolSchema>() const consumedPromptChanges = new Set<number>() + let previousHeader: TrajectoryRequestHeaderState | undefined + let previousTools: ReadonlyMap<string, ToolSchema> = 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<RequestView, { purpose: 'assistant' }> { @@ -15,6 +17,47 @@ function assistantRequest(startSeq: number, step: number): Extract<RequestView, } } +function contribution( + key: string, + anchorSeq: number, + data: TrajectoryContribution, +): TrajectoryConversationViewNode { + return { key, kind: key, id: key, target: 'trajectory', anchorSeq, data } +} + +function stepLocation(turn: number, step: number): TrajectoryRequestHeaderState['location'] { + const data = { get: () => undefined } + const stepLocation = { + turn, + step, + start: undefined, + end: undefined, + status: 'unknown' as const, + data, + } + const turnLocation = { + turn, + start: undefined, + end: undefined, + status: 'unknown' as const, + steps: [stepLocation], + data, + } + return { kind: 'step', turn: turnLocation, step: stepLocation } +} + +function compactionRequest(startSeq: number): Extract<RequestView, { purpose: 'compaction' }> { + 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 202/229] 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<number, ConversationNode> + nodes: Map<string, ConversationNode> startSeq: number retainedSurfaceSeqs: Set<number> } +/** + * 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 203/229] 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<ReviewState> = { 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<ReviewState> = { 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<ReviewState> = { 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<ReviewState> = { 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<string> { + 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<number> = { @@ -368,6 +381,7 @@ describe('ConversationNodeAssembler', () => { : null, start: (_context, _match, reader) => reader.previous<number>('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<number> = { @@ -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<number> = { @@ -506,6 +525,7 @@ describe('ConversationNodeAssembler', () => { + (reader.previous<number>('diamond-x')?.state ?? 0) ), update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const consumer: ConversationNodeDefinition<number> = { @@ -518,6 +538,7 @@ describe('ConversationNodeAssembler', () => { + (reader.previous<number>('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<null> = { + 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<null> = { + 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<null> = { 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<string> = { - 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<null> = { + kind: 'target-only', + target: 'chat', + match: () => null, + start: () => null, + update: context => context.state, + } + const builderOnly: ConversationNodeDefinition<null> = { + 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<null> = { + 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<ReadonlySet<number>>(EMPTY_TURN_IDS) const [collapsedAssistants, setCollapsedAssistants] = useState<ReadonlySet<string>>(EMPTY_RECORD_IDS) - const [timelineSelection, setTimelineSelection] = useState<{ - branchKey: string - range: TrajectoryTimeRange - } | null>(null) + const [timelineSelection, setTimelineSelection] = useState<TrajectoryTimeRange | null>(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<readonly ConversationContext[]>( - () => 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<readonly TrajectoryRequestNumber[]>(() => { const assistantsByStep = new Map<string, AssistantMessageNode>() - 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<ConversationSnapshot['partial']>(() => 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({ /> <div className={css.ledger}> <TrajectoryTable - key={currentBranch.key} requestNumbers={requestNumbers} turns={timelineTurns} streamingCells={streamingCells} diff --git a/packages/client/ui-trajectory/src/client/context-branches.ts b/packages/client/ui-trajectory/src/client/context-branches.ts deleted file mode 100644 index 2f665bb413..0000000000 --- a/packages/client/ui-trajectory/src/client/context-branches.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** Rewind-delimited trajectory branches assembled across surface rewrites. */ - -import type { - ConversationContext, ConversationNode, RequestView, -} from '@deepseek-ai/dsh-client-runtime/client' - -/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */ -export interface TrajectoryContextBranch { - id: number - /** Identity stable when older context generations are prepended. */ - key: string - contexts: readonly ConversationContext[] - latest: ConversationContext - nodes: readonly ConversationNode[] - /** Seq that opened this branch; earlier requests require retained cited surface events. */ - startSeq: number - /** Exact pre-rewind surface records inherited by this branch. */ - retainedSurfaceSeqs: ReadonlySet<number> -} - -interface MutableBranch { - id: number - key: string - contexts: ConversationContext[] - latest: ConversationContext - nodes: Map<string, ConversationNode> - startSeq: number - retainedSurfaceSeqs: Set<number> -} - -/** - * 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<TurnEndState> = { ...(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<string, ConversationPromptSnapshot['tools'][number]> - 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<MessageNode> = { ? 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<RequestView, { purpose: 'assistant' }> 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<string>() + 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<string>() + 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<ToolState> = { 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<string, unknown> = {}, +): 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( - <TrajectoryView - {...standaloneProps([])} - {...standaloneDuration()} - useSession={bindSnapshotSelector(store)} - loadOlder={vi.fn(() => Promise.resolve(false))} - />, - ) - - expect(screen.queryByText('abandoned response')).toBeNull() - expect(screen.getByText('current response')).toBeTruthy() - expect(screen.getByRole('row', { name: /Request 2, ASSISTANT/ })).toBeTruthy() - expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0) - }) - - it('does not remount the ledger when prepending shifts a rewind generation id', () => { - const current = { - kind: 'assistant', - seq: 5, - time: 5_000, - turn: 2, - step: 1, - blocks: [{ kind: 'text', text: 'stable rewind response' }], - } as unknown as ConversationSnapshot['nodes'][number] - const snapshot = (id: number) => historySnapshot([current], { - contexts: [{ - id, - origin: 'rewind' as const, - originSeq: 4, - nodes: [current], - }], - }) - const store = createSnapshotStore(snapshot(1)) - render( - <TrajectoryView - {...standaloneProps([])} - {...standaloneDuration()} - useSession={bindSnapshotSelector(store)} - loadOlder={vi.fn(() => Promise.resolve(false))} - />, - ) - const row = screen.getByRole('row', { name: /stable rewind response/ }) - fireEvent.click(row) - expect(row.getAttribute('aria-selected')).toBe('true') - - act(() => { store.set(snapshot(2)) }) - - expect(screen.getByRole('row', { name: /stable rewind response/ }) - .getAttribute('aria-selected')).toBe('true') - }) - it('keeps ledger and timeline selection on the same event after prepend', () => { const older = { 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( - <TrajectoryView - {...standaloneProps([])} - {...standaloneDuration()} - useSession={bindSnapshotSelector(store)} - loadOlder={vi.fn(() => Promise.resolve(false))} - />, - ) - - expect(screen.getByText('partial response retained')).toBeTruthy() - expect(screen.getByRole('row', { name: /TOOL, bash/ })).toBeTruthy() - }) }) describe('node half', () => { diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index f525474d9d..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 204/229] 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 205/229] 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 206/229] 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 207/229] 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<AnchorProps> }) { +export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) { const anchor = useRef<HTMLElement | null>(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<HTMLSpanElement | null>(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<ReturnType<typeof setTimeout> | 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 && ( <span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip"> - {label} + {resolvedLabel} </span> )} </> 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( + <Tooltip label={label} delayMs={500}> + <button type="button">anchor</button> + </Tooltip>, + ) + 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<RecordPresentationValue, 'displayText' | 'toolCallOnly' | 'toolCallText'>) { + if (toolCallOnly) { + return <span className={css.toolCallOnly}>(tool call only)</span> + } + if (toolCallText === undefined) return displayText || '—' + return ( + <> + <span className={css.toolCallNameTypeface}> + {toolCallText.name || '—'} + </span> + {toolCallText.args !== undefined && ( + <span className={css.toolCallPayload}> + {toolCallText.args} + </span> + )} + </> + ) +} + function MarkdownFragment({ text, rendered, @@ -2131,15 +2208,12 @@ export function TrajectoryTable({ /> </tr> )} - {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 }) => ( + <RecordPresentation + key={trajectoryVirtualRecordKey(record)} + 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' @@ -2169,7 +2243,6 @@ export function TrajectoryTable({ : activeTurn === record.turn return ( <tr - key={trajectoryVirtualRecordKey(record)} tabIndex={isRequestOnly ? -1 : 0} aria-rowindex={position + 1} aria-label={isCollapsedSummary @@ -2348,37 +2421,26 @@ export function TrajectoryTable({ ) : ( <span - className={record.cell.result === undefined ? css.contentText : css.resultPreview} - title={record.cell.result === undefined + className={resultText === undefined ? css.contentText : css.resultPreview} + title={resultText === undefined ? listDisplayText - : `${listDisplayText} → ${record.cell.result}`} + : `${listDisplayText} → ${resultText}`} > - <span className={record.cell.result === undefined ? undefined : css.resultRequest}> - {toolCallOnly - ? <span className={css.toolCallOnly}>(tool call only)</span> - : toolCallText === undefined - ? listDisplayText || '—' - : ( - <> - <span className={css.toolCallNameTypeface}> - {toolCallText.name || '—'} - </span> - {toolCallText.args !== undefined && ( - <span className={css.toolCallPayload}> - {toolCallText.args} - </span> - )} - </> - )} + <span className={resultText === undefined ? undefined : css.resultRequest}> + <RecordListText + displayText={displayText} + toolCallOnly={toolCallOnly} + toolCallText={toolCallText} + /> </span> - {record.cell.result !== undefined && ( + {resultText !== undefined && ( <span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}> <span className={css.arrow}>→</span> - <span className={record.cell.result === 'No output' + <span className={resultText === 'No output' ? `${css.inlineResultText} ${css.noOutputText}` : css.inlineResultText} > - {record.cell.result} + {resultText} </span> </span> )} @@ -2387,7 +2449,9 @@ export function TrajectoryTable({ </td> </tr> ) - })} + }} + </RecordPresentation> + ))} {virtualBottom > 0 && ( <tr className={css.virtualSpacer} data-virtual-spacer="bottom" aria-hidden="true"> <td diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index 1cf92c8d78..b1448b8536 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -687,7 +687,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ return ( <Tooltip key={span.index} - label={timelineTooltipLabel(span.kind, detail)} + label={() => 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<number> = new Set() const EMPTY_RECORD_IDS: ReadonlySet<string> = 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<typeof deriveTrajectoryLayout>, - query: string, -): ReadonlySet<number> | null { - const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean) - if (terms.length === 0) return null - const matches = new Set<number>() - 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<number> | null, - partial: ReadonlySet<number> | null, -): ReadonlySet<number> | 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<ReturnType<typeof setTimeout> | null>(null) + const searchIndexInitialized = useRef(false) const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(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<number>() + 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<RequestView, { purpose: 'assistant' }> type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }> -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<TrajectoryCellProps, 'text' | 'previewMarkdown'> { + return { + text: name, + ...(argsRaw === '' ? {} : { previewMarkdown: argsRaw }), + } } -function summarizeResult(node: ToolResultNode): string { +function summarizeResult( + node: ToolResultNode, +): Pick<TrajectoryCellProps, 'result' | 'resultPreviewMarkdown'> { 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<TrajectoryCellProps, 'result' | 'resultPreviewMarkdown'> | undefined, +): Pick<TrajectoryCellProps, 'text' | 'previewMarkdown'> { + 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<TrajectoryCellProps, 'previewMarkdown'> { + 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<HTMLDivElement> { /** 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<HTMLDivElement> { 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<string, SearchEntry>() + 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<string>() + 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<string> | null { + const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean) + if (terms.length === 0) return null + const matches = new Set<string>() + 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 208/229] 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 209/229] 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 ( - <tr - tabIndex={isRequestOnly ? -1 : 0} - aria-rowindex={position + 1} - aria-label={isCollapsedSummary - ? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}` - : isRequestOnly - ? `Request ${request ?? ''}, compaction` - : `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`} - aria-selected={!isCollapsedSummary && !isRequestOnly && selectedIndex === record.cell.index} - data-kind={record.cell.kind} - data-trajectory-row-key={trajectoryVirtualRecordKey(record)} - data-virtual-position={virtualizationEnabled ? position : undefined} - data-record-index={!isCollapsedSummary && !isRequestOnly - ? record.cell.index - : undefined} - data-request-only={isRequestOnly || undefined} - data-terminal-request-boundary={terminalRequestBoundary || undefined} - data-group-start={record.groupStart || undefined} - data-turn-start={record.turnStart || undefined} - data-error={record.cell.isError || undefined} - data-running={stateOf(record) === 'running' || undefined} - data-turn-end={record.turnEnd || undefined} - data-collapsed-summary={record.collapsedSummaryKind} - data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined} - data-timeline-focus={isCollapsedSummary || timelineFocusIndexes === null - ? undefined - : timelineFocusIndexes.has(record.cell.index) ? 'inside' : 'outside'} - onClick={isRequestOnly - ? undefined - : isCollapsedSummary - ? () => { - if (record.collapsedSummaryKind === 'turn' && record.turn !== null) { + const sectionActive = record.turn === null + ? activeSection === record.section + : activeTurn === record.turn + return ( + <tr + tabIndex={isRequestOnly ? -1 : 0} + aria-rowindex={position + 1} + aria-label={isCollapsedSummary + ? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}` + : isRequestOnly + ? `Request ${request ?? ''}, compaction` + : `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`} + aria-selected={!isCollapsedSummary && !isRequestOnly && selectedIndex === record.cell.index} + data-kind={record.cell.kind} + data-trajectory-row-key={trajectoryVirtualRecordKey(record)} + data-virtual-position={virtualizationEnabled ? position : undefined} + data-record-index={!isCollapsedSummary && !isRequestOnly + ? record.cell.index + : undefined} + data-request-only={isRequestOnly || undefined} + data-terminal-request-boundary={terminalRequestBoundary || undefined} + data-group-start={record.groupStart || undefined} + data-turn-start={record.turnStart || undefined} + data-error={record.cell.isError || undefined} + data-running={stateOf(record) === 'running' || undefined} + data-turn-end={record.turnEnd || undefined} + data-collapsed-summary={record.collapsedSummaryKind} + data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined} + data-timeline-focus={isCollapsedSummary || timelineFocusIndexes === null + ? undefined + : timelineFocusIndexes.has(record.cell.index) ? 'inside' : 'outside'} + onClick={isRequestOnly + ? undefined + : isCollapsedSummary + ? () => { + 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) - }} - > - <td className={css.event}> - {request !== undefined && ( - <button - type="button" - className={requestSelected - ? `${css.requestBoundaryControl} ${css.requestBoundaryControlActive}` - : css.requestBoundaryControl} - aria-label={requestLabel} - aria-pressed={requestSelected} - data-label={requestLabel} - data-request-run-index={requestRunIndex} - data-request-status={requestStatus} - style={requestBoundaryStyle} - onClick={(event) => { - event.stopPropagation() - selectRequest({ - turn: record.turn, - group: record.group, - ...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }), - }) - }} - onDoubleClick={(event) => { event.stopPropagation() }} - /> - )} - {record.turn !== null + }} + onKeyDown={(event) => { + if (isRequestOnly) return + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + if (isCollapsedSummary) { + if (record.collapsedSummaryKind === 'turn' && record.turn !== null) { + onToggleTurn(record.turn) + } else onToggleAssistant(trajectoryRecordId(record.cell)) + return + } + selectRecord(record.cell.index) + }} + > + <td className={css.event}> + {request !== undefined && ( + <button + type="button" + className={requestSelected + ? `${css.requestBoundaryControl} ${css.requestBoundaryControlActive}` + : css.requestBoundaryControl} + aria-label={requestLabel} + aria-pressed={requestSelected} + data-label={requestLabel} + data-request-run-index={requestRunIndex} + data-request-status={requestStatus} + style={requestBoundaryStyle} + onClick={(event) => { + event.stopPropagation() + selectRequest({ + turn: record.turn, + group: record.group, + ...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }), + }) + }} + onDoubleClick={(event) => { event.stopPropagation() }} + /> + )} + {record.turn !== null && activeTurn === record.turn && !isInitialSystem && ( - <span className={css.turnRail} aria-hidden="true" /> - )} - {!isCollapsedSummary && selectedIndex === record.cell.index && ( - <span className={css.selectionRail} aria-hidden="true" /> - )} - {!isCollapsedSummary + <span className={css.turnRail} aria-hidden="true" /> + )} + {!isCollapsedSummary && selectedIndex === record.cell.index && ( + <span className={css.selectionRail} aria-hidden="true" /> + )} + {!isCollapsedSummary && !isRequestOnly && record.turnStart && ( - <span - className={sectionActive - ? `${css.turnLabel} ${css.turnLabelActive}` - : css.turnLabel} - aria-label={sectionLabel(record.turn)} - > - {record.turn === null - ? sectionLabel(record.turn) - : ( - <> - <span className={css.turnLabelFull} aria-hidden="true"> - {sectionLabel(record.turn)} - </span> - <span className={css.turnLabelCompact} aria-hidden="true"> - #{record.turn} - </span> - </> - )} - </span> - )} - <div className={css.eventInner}> - {!isCollapsedSummary && !isRequestOnly && ( - <span - className={css.kindSlot} - > <span - className={`${css.kindTag} ${ - record.cell.kind === 'system' - ? css.systemNeutral - : record.cell.kind === 'context' - ? css.contextGreen - : record.cell.kind === 'compacted' - ? css.compacted - : record.cell.kind === 'tool' - ? css.toolAmber - : record.cell.kind === 'message' - ? css.assistantVioletBright - : record.cell.kind === 'subtool' - ? css.subtoolAmber - : css[record.cell.kind] - }`} - data-role-kind={record.cell.kind} + className={sectionActive + ? `${css.turnLabel} ${css.turnLabelActive}` + : css.turnLabel} + aria-label={sectionLabel(record.turn)} > - <Tooltip - label={KIND_LABEL[record.cell.kind]} - side="right" - > - <span className={css.kindTagIcon} aria-hidden="true"> - {KIND_ICON[record.cell.kind]} - </span> - </Tooltip> - <span className={css.kindTagLabel}> - {KIND_LABEL[record.cell.kind]} - </span> - </span> - </span> - )} - </div> - </td> - <td className={css.content}> - {isRequestOnly - ? null - : record.collapsedSummary !== undefined - ? ( - <span className={css.collapsedTurnContent} title={record.collapsedSummary}> - <span className={css.collapsedTurnEllipsis}>…</span> - <span className={css.collapsedTurnText}>{record.collapsedSummary}</span> - </span> - ) - : ( - <span - className={resultText === undefined ? css.contentText : css.resultPreview} - title={resultText === undefined - ? listDisplayText - : `${listDisplayText} → ${resultText}`} - > - <span className={resultText === undefined ? undefined : css.resultRequest}> - <RecordListText - displayText={displayText} - toolCallOnly={toolCallOnly} - toolCallText={toolCallText} - /> - </span> - {resultText !== undefined && ( - <span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}> - <span className={css.arrow}>→</span> - <span className={resultText === 'No output' - ? `${css.inlineResultText} ${css.noOutputText}` - : css.inlineResultText} - > - {resultText} - </span> - </span> - )} + {record.turn === null + ? sectionLabel(record.turn) + : ( + <> + <span className={css.turnLabelFull} aria-hidden="true"> + {sectionLabel(record.turn)} + </span> + <span className={css.turnLabelCompact} aria-hidden="true"> + #{record.turn} + </span> + </> + )} </span> )} - </td> - </tr> - ) + <div className={css.eventInner}> + {!isCollapsedSummary && !isRequestOnly && ( + <span + className={css.kindSlot} + > + <span + className={`${css.kindTag} ${ + record.cell.kind === 'system' + ? css.systemNeutral + : record.cell.kind === 'context' + ? css.contextGreen + : record.cell.kind === 'compacted' + ? css.compacted + : record.cell.kind === 'tool' + ? css.toolAmber + : record.cell.kind === 'message' + ? css.assistantVioletBright + : record.cell.kind === 'subtool' + ? css.subtoolAmber + : css[record.cell.kind] + }`} + data-role-kind={record.cell.kind} + > + <Tooltip + label={KIND_LABEL[record.cell.kind]} + side="right" + > + <span className={css.kindTagIcon} aria-hidden="true"> + {KIND_ICON[record.cell.kind]} + </span> + </Tooltip> + <span className={css.kindTagLabel}> + {KIND_LABEL[record.cell.kind]} + </span> + </span> + </span> + )} + </div> + </td> + <td className={css.content}> + {isRequestOnly + ? null + : record.collapsedSummary !== undefined + ? ( + <span className={css.collapsedTurnContent} title={record.collapsedSummary}> + <span className={css.collapsedTurnEllipsis}>…</span> + <span className={css.collapsedTurnText}>{record.collapsedSummary}</span> + </span> + ) + : ( + <span + className={resultText === undefined ? css.contentText : css.resultPreview} + title={resultText === undefined + ? listDisplayText + : `${listDisplayText} → ${resultText}`} + > + <span className={resultText === undefined ? undefined : css.resultRequest}> + <RecordListText + displayText={displayText} + toolCallOnly={toolCallOnly} + toolCallText={toolCallText} + /> + </span> + {resultText !== undefined && ( + <span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}> + <span className={css.arrow}>→</span> + <span className={resultText === 'No output' + ? `${css.inlineResultText} ${css.noOutputText}` + : css.inlineResultText} + > + {resultText} + </span> + </span> + )} + </span> + )} + </td> + </tr> + ) }} </RecordPresentation> ))} diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 3a118aadfb..6a24967ac2 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -943,8 +943,8 @@ function expandSubCalls( callId: sub.callId, ...(settled ? (sub.call !== null - ? summarizeCall(sub.call.name, sub.call.argsRaw) - : resultAsText(resultPreview)) + ? summarizeCall(sub.call.name, sub.call.argsRaw) + : resultAsText(resultPreview)) : summarizeCall(sub.name, sub.argsRaw)), ...(settled ? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {}) diff --git a/packages/client/ui-trajectory/src/client/trajectory-search-index.ts b/packages/client/ui-trajectory/src/client/trajectory-search-index.ts index 889fbff7e6..93dddb6856 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-search-index.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-search-index.ts @@ -97,13 +97,13 @@ export class TrajectorySearchIndex { const entry = previous !== undefined && sameSources(previous.sources, sources) ? previous : { - sources, - text: [ - ...sources, - markdownPreview(cell), - resultPreview(cell), - ].join('\n').toLocaleLowerCase(), - } + sources, + text: [ + ...sources, + markdownPreview(cell), + resultPreview(cell), + ].join('\n').toLocaleLowerCase(), + } this.entries.set(id, entry) seen.add(id) } From d66e693c635e9e26215407960308e8cc8d5d4967 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:06:11 +0800 Subject: [PATCH 210/229] fix(ui-trajectory): place steering before request boundary --- .../src/client/TrajectoryTable.tsx | 33 ++++-- .../src/client/TrajectoryView.tsx | 4 +- .../client/ui-trajectory/src/client/layout.ts | 91 +++++++++++++-- .../src/client/trajectory-contract.ts | 2 + .../client/trajectory-definition-common.ts | 1 + .../src/client/trajectory-snapshot-builder.ts | 4 + .../tests/conversation-definitions.spec.ts | 61 ++++++---- .../ui-trajectory/tests/layout.spec.tsx | 108 +++++++++++++++++- .../tests/snapshot-builder.spec.ts | 8 +- .../client/ui-trajectory/tests/table.spec.tsx | 37 ++++++ .../client/ui-trajectory/tests/views.spec.tsx | 1 + 11 files changed, 303 insertions(+), 47 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index a1db84f50c..ca7a96027a 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -492,6 +492,21 @@ function requestKey(turn: number | null, group: string): string { return `${turn}\u0000${group}` } +function indexRequestBoundaries(records: readonly TableRecord[]): ReadonlyMap<string, number> { + const boundaries = new Map<string, number>() + for (const record of records) { + const key = requestKey(record.turn, record.group) + if (boundaries.has(key)) continue + if (requestStep(record.group) === undefined) { + if (record.groupStart) boundaries.set(key, record.cell.index) + continue + } + if (record.cell.kind === 'user' || record.cell.kind === 'context') continue + boundaries.set(key, record.cell.index) + } + return boundaries +} + function sectionLabel(turn: number | null): string { return turn === null ? 'Between turns' : `Turn ${turn}` } @@ -499,16 +514,18 @@ function sectionLabel(turn: number | null): string { function indexRequestNumbers( records: readonly TableRecord[], sessionNumbers: readonly TrajectoryRequestNumber[] | undefined, + boundaries: ReadonlyMap<string, number>, ): ReadonlyMap<string, number> { const numbers = new Map<string, number>() for (const request of sessionNumbers ?? []) { numbers.set(requestKey(request.turn, request.group), request.number) } let next = Math.max(0, ...numbers.values()) + 1 - const boundaries = records - .filter(record => record.groupStart && requestStep(record.group) !== undefined) + const boundaryRecords = records + .filter(record => boundaries.get(requestKey(record.turn, record.group)) === record.cell.index + && requestStep(record.group) !== undefined) .sort((left, right) => left.cell.index - right.cell.index) - for (const record of boundaries) { + for (const record of boundaryRecords) { const key = requestKey(record.turn, record.group) if (!numbers.has(key)) numbers.set(key, next++) } @@ -1731,9 +1748,10 @@ export function TrajectoryTable({ useEffect(() => { onSelectedIndexChange?.(selectedIndex) }, [onSelectedIndexChange, selectedIndex]) + const requestBoundaries = useMemo(() => indexRequestBoundaries(allRecords), [allRecords]) const requestNumbers = useMemo( - () => indexRequestNumbers(allRecords, sessionRequestNumbers), - [allRecords, sessionRequestNumbers], + () => indexRequestNumbers(allRecords, sessionRequestNumbers, requestBoundaries), + [allRecords, requestBoundaries, sessionRequestNumbers], ) const records = useMemo(() => { if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes) @@ -2218,10 +2236,11 @@ export function TrajectoryTable({ const isRequestOnly = record.cell.requestOnly === true const isInitialSystem = record.cell.kind === 'system' && record.cell.index === allRecords[0]?.cell.index - const request = record.groupStart + const key = requestKey(record.turn, record.group) + const request = requestBoundaries.get(key) === record.cell.index && !isCollapsedSummary && (record.turn === null || !collapsedTurns.has(record.turn)) - ? requestNumbers.get(requestKey(record.turn, record.group)) + ? requestNumbers.get(key) : undefined const requestInfo = request === undefined ? undefined diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 2b2e50fc65..2e38aee078 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -145,6 +145,7 @@ export function TrajectoryView({ snapshot.openState === 'loading' || snapshot.loadingOlder) const hasOlderHistory = useSession(snapshot => snapshot.hasMore) const nodes = inspection.eventNodes + const eventLocations = inspection.eventLocations const historyBaseSeq = nodes[0]?.seq ?? 0 const partial = inspection.partial const runningCalls = inspection.runningCalls @@ -254,6 +255,7 @@ export function TrajectoryView({ const finalized = useMemo(() => { const turns = deriveTrajectoryLayout({ nodes, + eventLocations, partial: partialTurn === null || partialStep === null ? null : { turn: partialTurn, step: partialStep, blocks: [] }, @@ -263,7 +265,7 @@ export function TrajectoryView({ }) return { turns, lastIndex: lastCellIndex(turns) } }, [ - nodes, partialTurn, partialStep, + nodes, eventLocations, partialTurn, partialStep, runningCalls, requests, callSchemas, ]) const timelinePartialSignature = partialStructureSignature(partial) diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 6a24967ac2..265d5ba034 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -5,6 +5,7 @@ import type { AssistantBlock, AssistantMessageNode, + ConversationLocation, ConversationSnapshot, RequestInspectionSnapshot, RequestPromptChange, @@ -34,6 +35,7 @@ export interface TrajectoryTurnModel { /** Snapshot slice the trajectory view folds. */ export interface TrajectoryLayoutInput { nodes: ConversationSnapshot['nodes'] + eventLocations?: ReadonlyMap<number, ConversationLocation> partial: ConversationSnapshot['partial'] runningCalls: ConversationSnapshot['runningCalls'] requests?: readonly RequestView[] @@ -71,7 +73,7 @@ type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }> type InputNode = Extract< ConversationSnapshot['nodes'][number], - { kind: 'user' | 'context' } + { kind: 'user' | 'steering' | 'context' } > type OrderedLayoutEntry = @@ -135,12 +137,13 @@ function inputCellDetail(node: InputNode): Pick< */ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] { const { - nodes, partial, runningCalls, requests = [], callSchemas, + nodes, eventLocations, partial, runningCalls, requests = [], callSchemas, } = input const resultByCall = indexResults(nodes) const callById = new Map<string, ToolCallBlock>(resultByCall) for (const call of runningCalls) callById.set(call.callId, call) const emittedCallIds = indexAssistantCallIds(nodes) + const followingAssistants = indexFollowingAssistants(nodes) const callStartById = new Map<string, number>() for (const result of resultByCall.values()) { const startedAt = finiteTime(result.callTime) @@ -185,6 +188,19 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T } groups.push({ title, laid: [...laid] }) } + const pushStepInput = (turn: number, step: number, laid: readonly LaidCell[]) => { + if (laid.length === 0) return + const groups = bucket(turn).groups + const title = `Step ${step}` + const existing = groups.find(group => group.title === title) + if (existing === undefined) { + groups.push({ title, laid: [...laid] }) + return + } + const request = existing.laid.findIndex(entry => entry.cell.requestOnly === true) + if (request === -1) existing.laid.push(...laid) + else existing.laid.splice(request, 0, ...laid) + } const representedRequests = new Set<string>() for (const node of nodes) { @@ -338,7 +354,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T if (node.kind === 'user') { // user/message has no turn on the wire; enclose it in the next assistant // (or partial) turn, else open the turn after the last assistant. - const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn) + const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn) pushMessage(turn, { absTime: finiteTime(node.time), cell: { @@ -351,6 +367,26 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T prevAbsTime = finiteTime(node.time) ?? prevAbsTime continue } + if (node.kind === 'steering') { + const placement = steeringPlacement( + followingAssistants[i], + partial, + lastAssistantTurn, + eventLocations?.get(node.seq), + ) + const laid = { + absTime: finiteTime(node.time), + cell: { + index: ++index, + kind: 'user' as const, + ...inputCellDetail(node), + }, + } + if (placement.step === undefined) pushMessage(placement.turn, laid) + else pushStepInput(placement.turn, placement.step, [laid]) + prevAbsTime = finiteTime(node.time) ?? prevAbsTime + continue + } if (node.kind === 'assistant') { const laidList = withSubCalls( expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById), @@ -364,7 +400,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T continue } if (node.kind === 'context') { - const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn) + const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn) pushMessage(turn, { absTime: finiteTime(node.time), cell: { @@ -821,22 +857,53 @@ function stringifySourceValue(value: unknown): string { * in-flight partial, else the turn after the last finalized assistant (or 1). */ function enclosingUserTurn( - nodes: ConversationSnapshot['nodes'], - userIndex: number, + followingAssistant: AssistantMessageNode | undefined, partial: ConversationSnapshot['partial'], lastAssistantTurn: number | null, ): number { - for (let i = userIndex + 1; i < nodes.length; i++) { - const n = nodes[i] - /* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */ - if (n === undefined) continue - if (n.kind === 'assistant') return n.turn - } + if (followingAssistant !== undefined) return followingAssistant.turn if (partial !== null) return partial.turn if (lastAssistantTurn !== null) return lastAssistantTurn + 1 return 1 } +function steeringPlacement( + followingAssistant: AssistantMessageNode | undefined, + partial: ConversationSnapshot['partial'], + lastAssistantTurn: number | null, + location: ConversationLocation | undefined, +): { turn: number; step?: number } { + if (location?.kind === 'step') { + return { turn: location.turn.turn, step: location.step.step } + } + const locatedTurn = location?.kind === 'turn' ? location.turn.turn : undefined + if (followingAssistant !== undefined + && (locatedTurn === undefined || followingAssistant.turn === locatedTurn)) { + return { + turn: followingAssistant.turn, + ...(followingAssistant.step > 0 ? { step: followingAssistant.step } : {}), + } + } + if (partial !== null && (locatedTurn === undefined || partial.turn === locatedTurn)) { + return { turn: partial.turn, ...(partial.step > 0 ? { step: partial.step } : {}) } + } + if (locatedTurn !== undefined) return { turn: locatedTurn } + return { turn: lastAssistantTurn ?? 1 } +} + +function indexFollowingAssistants( + nodes: ConversationSnapshot['nodes'], +): readonly (AssistantMessageNode | undefined)[] { + const following = new Array<AssistantMessageNode | undefined>(nodes.length) + let assistant: AssistantMessageNode | undefined + for (let index = nodes.length - 1; index >= 0; index--) { + following[index] = assistant + const node = nodes[index] + if (node?.kind === 'assistant') assistant = node + } + return following +} + function enclosingPromptTurn( nodes: ConversationSnapshot['nodes'], seq: number, diff --git a/packages/client/ui-trajectory/src/client/trajectory-contract.ts b/packages/client/ui-trajectory/src/client/trajectory-contract.ts index 3e877a7969..7f1c1feea9 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-contract.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-contract.ts @@ -53,12 +53,14 @@ export type TrajectoryContribution = export interface TrajectoryConversationViewNode extends ConversationViewNode { readonly target: 'trajectory' readonly anchorSeq: number + readonly location: ConversationLocation readonly data: TrajectoryContribution } /** Stage-oriented Trajectory data assembled from registered business Contexts. */ export interface TrajectorySnapshot { readonly eventNodes: readonly ConversationNode[] + readonly eventLocations: ReadonlyMap<number, ConversationLocation> readonly requests: readonly RequestView[] readonly callSchemas: ReadonlyMap<string, ConversationPromptSnapshot['tools'][number]> readonly partial: PartialAssistant | null 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 639b1ad3ea..d55d5ca542 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts @@ -22,6 +22,7 @@ export function trajectoryNode( id: context.id, target: 'trajectory', anchorSeq, + location: context.start?.location ?? { kind: 'unresolved' }, data, } } 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 dcc5f2edbc..8ca382bcc5 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -16,6 +16,7 @@ type ToolSchema = ConversationPromptSnapshot['tools'][number] /** Stable empty target used until a Session has assembled Trajectory records. */ export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = { eventNodes: EMPTY_LIST, + eventLocations: new Map(), requests: EMPTY_LIST, callSchemas: new Map(), partial: null, @@ -179,6 +180,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< if (key !== undefined) headersByStep.set(key, contribution.data.header) } const finalized: ConversationNode[] = [] + const eventLocations = new Map<number, TrajectoryConversationViewNode['location']>() const requests: RequestView[] = [] const boundaries: { seq: number; time: number }[] = [] const turnEndings: { turn: number; time: number; error?: string }[] = [] @@ -198,6 +200,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< } if (data.kind === 'node') { finalized.push(data.node) + eventLocations.set(data.node.seq, contribution.location) continue } if (data.kind === 'assistant') { @@ -244,6 +247,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< const eventNodes = finalized return { eventNodes, + eventLocations, requests, callSchemas, partial, diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts index 631d283f21..9999169896 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts @@ -228,21 +228,9 @@ describe('Trajectory conversation Definitions', () => { }) 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', { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'request/header', { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, @@ -250,22 +238,45 @@ describe('Trajectory conversation Definitions', () => { tools: [], }, }), - at(6, 'step/start', { turn: 1, step: 1 }), - at(7, 'assistant/message', { + at(3, 'step/start', { turn: 1, step: 1 }), + at(4, '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'), + at(5, 'step/end', { turn: 1, step: 1 }), + at(6, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 0, inserted: [{ id: 'm1' }], }), - ])) + at(7, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + }), + at(8, 'step/start', { turn: 1, step: 2 }), + ]) + value.append(at(9, 'user/message', { + id: 'm1', + role: 'user', + content: [{ type: 'text', text: 'steer here' }], + source: { kind: 'user' }, + })) + value.flush() + + const steering = snapshot(value) + expect(steering.eventNodes.find(node => node.seq === 9)?.kind).toBe('steering') + expect(steering.eventLocations.get(9)).toMatchObject({ + kind: 'step', + turn: { turn: 1 }, + step: { step: 2 }, + }) + + value.append(at(10, 'assistant/message', { + turn: 1, + step: 2, + message: assistantMessage('assistant-2', 'second'), + })) + value.flush() + const current = snapshot(value) - expect(current.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']) diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index d04c95b46d..ec8924505f 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' import type { - ConversationSnapshot, RequestView, + ConversationLocation, ConversationSnapshot, RequestView, } from '@deepseek-ai/dsh-client-runtime/client' import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx' import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx' @@ -239,6 +239,112 @@ describe('deriveTrajectoryLayout', () => { ]) }) + it('places steering in its resolved step instead of the turn-opening Message group', () => { + const nodes = [ + { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'start' }], source: null }, + { + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, + blocks: [{ kind: 'text', text: 'first step' }], + }, + { + kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000, + content: [{ type: 'text', text: 'change direction' }], source: null, + }, + { + kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2, + blocks: [{ kind: 'text', text: 'second step' }], + }, + ] as unknown as ConversationSnapshot['nodes'] + const data = { get: () => undefined } + const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data } + const turn = { + turn: 1, start: undefined, end: undefined, status: 'open' as const, steps: [step], data, + } + const eventLocations = new Map<number, ConversationLocation>([[ + 3, + { kind: 'step', turn, step }, + ]]) + + const turns = deriveTrajectoryLayout({ + nodes, + eventLocations, + partial: null, + runningCalls: [], + }) + + expect(turns).toHaveLength(1) + expect(turns[0]?.groups.map(group => group.title)).toEqual([ + 'Message', 'Step 1', 'Step 2', + ]) + expect(turns[0]?.groups[2]?.cells).toMatchObject([ + { kind: 'user', previewMarkdown: 'change direction', sourceSeq: 3 }, + { kind: 'message', previewMarkdown: 'second step', sourceSeq: 4 }, + ]) + }) + + it('keeps a running request boundary after steering input', () => { + const nodes = [{ + kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000, + content: [{ type: 'text', text: 'change direction' }], source: null, + }] as unknown as ConversationSnapshot['nodes'] + const data = { get: () => undefined } + const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data } + const turn = { + turn: 1, start: undefined, end: undefined, status: 'open' as const, steps: [step], data, + } + const eventLocations = new Map<number, ConversationLocation>([[ + 3, + { kind: 'step', turn, step }, + ]]) + + const turns = deriveTrajectoryLayout({ + nodes, + eventLocations, + partial: null, + runningCalls: [], + requests: [{ + purpose: 'assistant', + startSeq: 2, + turn: 1, + step: 2, + startedAt: 2_000, + completedAt: null, + status: 'running', + }], + }) + + expect(turns[0]?.groups[0]?.cells).toMatchObject([ + { kind: 'user', previewMarkdown: 'change direction', sourceSeq: 3 }, + { kind: 'message', requestOnly: true, sourceSeq: 2 }, + ]) + }) + + it('uses the following assistant step while a historical window lacks steering Location', () => { + const nodes = [ + { + kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000, + content: [{ type: 'text', text: 'change direction' }], source: null, + }, + { + kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 3, + blocks: [{ kind: 'text', text: 'continued' }], + }, + ] as unknown as ConversationSnapshot['nodes'] + + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + + expect(turns[0]).toMatchObject({ + turn: 2, + groups: [{ + title: 'Step 3', + cells: [ + { kind: 'user', previewMarkdown: 'change direction' }, + { kind: 'message', previewMarkdown: 'continued' }, + ], + }], + }) + }) + it('places standalone compaction chronologically in its own between-turn section', () => { const nodes = [ { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null }, diff --git a/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts b/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts index d3cf64ac2d..c0058b75c6 100644 --- a/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts +++ b/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts @@ -22,7 +22,11 @@ function contribution( anchorSeq: number, data: TrajectoryContribution, ): TrajectoryConversationViewNode { - return { key, kind: key, id: key, target: 'trajectory', anchorSeq, data } + return { + key, kind: key, id: key, target: 'trajectory', anchorSeq, + location: { kind: 'session' }, + data, + } } function stepLocation(turn: number, step: number): TrajectoryRequestHeaderState['location'] { @@ -72,6 +76,7 @@ describe('TrajectorySnapshotBuilder', () => { id: '2', target: 'trajectory', anchorSeq: 2, + location: { kind: 'session' }, data: { kind: 'request-header', header: { @@ -89,6 +94,7 @@ describe('TrajectorySnapshotBuilder', () => { id: `1:${request.step}`, target: 'trajectory' as const, anchorSeq: request.startSeq, + location: { kind: 'session' as const }, data: { kind: 'assistant' as const, partial: null, request }, })), ] diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index dc4d2c9188..b610b278f6 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -308,6 +308,43 @@ describe('TrajectoryTable', () => { expect(screen.getByText('Request #2')).toBeTruthy() }) + it('places the request boundary after leading steering input', () => { + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 2', + cells: [{ + index: 1, + kind: 'user', + sourceSeq: 3, + text: 'change direction', + timeSeconds: 0, + }, { + index: 2, + kind: 'message', + sourceSeq: 4, + text: 'continued', + timeSeconds: 1, + }], + }], + }] + + render(<TrajectoryTable + turns={turns} + requestNumbers={[{ + seq: 2, + turn: 1, + step: 2, + group: 'Step 2', + number: 1, + }]} + {...FOLD_PROPS} + />) + + const request = screen.getByRole('button', { name: 'Request #1' }) + expect(request.closest('tr')?.getAttribute('aria-label')).toContain('ASSISTANT') + }) + it('follows appended records only while the ledger is already at the bottom', () => { const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />) const tablePane = screen.getByRole('table').parentElement as HTMLElement diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 88c7f41ccf..e4ea44d925 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -75,6 +75,7 @@ function historySnapshot( ): ConversationSnapshot { const trajectory: TrajectorySnapshot = { eventNodes: nodes, + eventLocations: new Map(), requests: [], callSchemas: new Map(), partial: null, From 1af8c96682d6be058487cdad0dadb48cb22950f7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:13:13 +0800 Subject: [PATCH 211/229] perf(ui-trajectory): avoid locale formatting in hot path --- packages/client/ui-trajectory/src/client/trajectory-record.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 38101f8233..e4cd6e6e02 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -116,7 +116,8 @@ export function trajectoryRecordId(cell: TrajectoryCellProps): string { */ export function formatDurationMillis(milliseconds: number | null): string { if (milliseconds === null || !Number.isFinite(milliseconds)) return '—' - return `${Math.round(milliseconds).toLocaleString('en-US')} ms` + const integer = String(Math.round(milliseconds)) + return `${integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} ms` } /** From 3f7c2a25b618d73de4009a66c2bf956619ca0aaa Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:37:34 +0800 Subject: [PATCH 212/229] docs: record trajectory context assembly --- ...lient-conversation-node-assembly.i18n.yaml | 4 +- ...08-09-client-conversation-node-assembly.md | 2 +- ...09-client-conversation-node-assembly.zh.md | 2 +- ...ry-conversation-context-assembly.i18n.yaml | 6 + ...rajectory-conversation-context-assembly.md | 105 ++++++++++++++++++ ...ectory-conversation-context-assembly.zh.md | 105 ++++++++++++++++++ ...-27-trajectory-inspection-ledger.i18n.yaml | 4 +- ...2026-07-27-trajectory-inspection-ledger.md | 2 +- ...6-07-27-trajectory-inspection-ledger.zh.md | 2 +- 9 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md create mode 100644 .agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md 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 3d569b7ce2..42600f1c56 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 3b02fde8b5c8da0c7086a2de65a5ae8eea8b2526 -2026-08-09-client-conversation-node-assembly.zh.md: 2ddb14c35b3ac5aeba5b00e7d56b3a4e69a97adb +2026-08-09-client-conversation-node-assembly.md: f6cd7ea94d485d92fd4ea178751c08bd01ecb4b2 +2026-08-09-client-conversation-node-assembly.zh.md: e550b7870a611ec625d7c2a738bf71947825ea58 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 3b02fde8b5..f6cd7ea94d 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -330,7 +330,7 @@ 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. +The target-specific Trajectory Definitions, retained stage model, Steering adaptation, complexity bounds, and presentation hot paths are owned by the [Trajectory Context assembly decision](2026-08-11-trajectory-conversation-context-assembly.md). ## Runtime and render path 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 2ddb14c35b..e550b7870a 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -330,7 +330,7 @@ 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。展示缓存与搜索索引互不借用生命周期。 +target 专属 Trajectory Definition、保留的 stage model、Steering 适配、复杂度上界与表现层热点由 [Trajectory Context 组装决策](2026-08-11-trajectory-conversation-context-assembly.md)负责。 ## Runtime and render path diff --git a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml new file mode 100644 index 0000000000..6db3babb64 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md +2026-08-11-trajectory-conversation-context-assembly.md: 7d0aea2fc09f0f04bd5de923bee15c42a773489a +2026-08-11-trajectory-conversation-context-assembly.zh.md: be3903bc62a8b1cc21bd9ddac450541ad4679ff0 diff --git a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md new file mode 100644 index 0000000000..7d0aea2fc0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md @@ -0,0 +1,105 @@ +# Agent Note: Trajectory assembly from registered Conversation Contexts + +Status: implemented + +English | [中文](2026-08-11-trajectory-conversation-context-assembly.zh.md) + +## Problem + +Trajectory maintained an independent Session History source and folded the complete loaded Event window into Assistant, Tool, message, Request-header, and Compaction state. Chat already assembled the same Event families through registered Conversation Definitions. The two paths duplicated business correlation and pagination behavior, and a Trajectory structural update copied or rescanned work proportional to the raw Event count even when one business object changed. + +Reusing Chat's final Nodes would not solve the ownership problem. Trajectory needs request lifecycles, running Assistant state, prompt inheritance, Tool schemas, timing records, and a stage-oriented read model that Chat does not consume. Sharing final Node payloads would couple both views to the union of their requirements. + +The migration also had to preserve durable steering classification. A `user/message` does not say whether it opened a Turn or was claimed from the `next-step` inbox, and an older page can supply the missing inbox predecessor or Location after the message has already materialized. + +## Decision + +Trajectory registers target-owned Conversation Definitions and a `trajectory` View Builder against the shared [`ConversationNodeAssembler`](2026-08-09-client-conversation-node-assembly.md). Session owns one contiguous Event window and publishes both Chat and Trajectory snapshots through `Session.views`; it does not run a second Trajectory history source or business fold. + +Each Definition belongs to one target. Chat and Trajectory may recognize the same durable Event family, but they keep separate State and final Node payloads. They share only the Assembler's exact-ID matching, ordered Matches, Location facts, Reader dependencies, publication scheduling, and replace/prepend/append lifecycle. + +The existing [Trajectory inspection ledger](../feature/2026-07-27-trajectory-inspection-ledger.md) remains the view model. The Trajectory Builder converts materialized target Nodes into its established `eventNodes`, Requests, Tool schemas, running calls, and Location map; layout, table virtualization, selection, Overview, and inspector behavior do not become generic Conversation contracts. + +### Business Definitions + +| Business | Context identity | State assembly | Trajectory contribution | +|---|---|---|---| +| `next-step` inbox | splice Event seq | Apply the splice to the nearest preceding inbox Context | State only; no visible Node | +| User, steering, or injected message | message Event seq | Read the preceding inbox State and classify the durable message | Input or context Node | +| Assistant and ordinary Request | `turn:step` | Fold `step/start`, chunks, final message, retry, and `step/end` | Final Assistant, partial Assistant, and Request | +| Root Tool call | root call ID | Fold root call/result and nested Code Dispatch events into one call tree | Final or running Tool tree | +| Compaction | compaction ID | Fold start, summary, end, and replacement checkpoint | Compaction Request | +| Request header | header Event seq | Read the preceding header and retain effective prompt plus the actual change | Prompt and Tool-schema source | +| Session and Turn boundaries | boundary Event seq | Retain closure time and error facts | Interrupted Compaction or failed ordinary Request | + +Every correlating Event must expose the same business ID directly. Code Dispatch uses `rootCallId`, Compaction uses its compaction ID, and ordinary Tool and retry events retain their protocol identities even when a specific Definition correlates by `turn:step`. Legacy records that lack the required correlation ID are ignored by that Definition rather than merged into an `undefined` Context or crashing the Session. + +Assistant chunks update only their `turn:step` Context. Content-bearing chunks request animation-frame publication; usage and finish chunks update State without forcing their own frame. A final message, retry, or boundary publishes immediately. Completed Assistant State retains assembled blocks, timing, usage, and retry facts rather than copying the raw chunk ledger into the target snapshot. + +### Steering from predecessor Contexts + +Trajectory reconstructs steering from durable inbox history, using the same identity rule as the [Chat steering decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) without sharing Chat's final Node. + +Each `agent/inbox/spliced` Event targeting `next-step` starts an invisible Context identified by its Event seq. Its `start()` reads the nearest earlier inbox Context, applies the splice, and stores the pending identities plus the cumulative set of claimed message IDs. A later user-origin `user/message` reads the nearest earlier inbox Context: a claimed ID produces a Steering Node, while every other user-origin message produces an ordinary User Node. + +A Reader miss while older history remains records a window-gap dependency. When prepend supplies the missing predecessor, the Assembler replays the affected inbox chain and message Contexts in forward Event order. Historical page direction therefore cannot permanently misclassify a message. + +The message Event's Location places steering in the owning Step. If the loaded history window lacks enough boundary Events to resolve that Location, layout uses the following Assistant step as the positional fallback. A running Request marker follows leading steering input in the same Step, so the marker denotes the model Request caused by that input rather than appearing before it. + +### Window paths and complexity + +Let `E` be the loaded raw Event count, `P` one newly prepended page, `D` the number of Trajectory Definitions, `C` the number of materialized Trajectory Context contributions, and `Mᵣ` the total Matches held by Contexts invalidated by a prepend. `D` is a small registered set; streaming chunks aggregate into one Assistant Context, so `C` is normally much smaller than `E`. + +| Path | Context work | Target snapshot work | Result | +|---|---|---|---| +| Initial tail or reconnect replace | Match the loaded window in `O(E × D)` and build State in forward Event order | Build and order `C` contributions | A full replace remains proportional to the loaded window | +| Older-page prepend | Match only fresh Events and replay only Contexts whose Match, Location, or Reader answer changed, in `O(P × D + Mᵣ)` | Rebuild the stage snapshot from `C` contributions | Business folding does not restart over all `E` Events | +| Live append | Match in `O(D)`, locate the keyed Context in `O(1)`, and update only that State | Replace a same-anchor contribution in `O(1)` before snapshot assembly | Business correlation is independent of loaded Event history | + +The Builder stores contributions by Context key and keeps a key-to-position index. A content update with the same anchor replaces one contribution in place; a new contribution or anchor change rebuilds and sorts contribution order. Snapshot assembly then walks `C` contributions, indexes Request headers and Tool schemas with Maps, and handles Compaction boundaries and Turn errors with linear cursors or indexes. + +Final Event and Request ordering keeps a publication's current upper bound at `O(C log C)`. The migration removes repeated reverse lookups and the old raw-history refold, but it does not claim end-to-end `O(1)` publication. Chat retains its existing keyed snapshot behavior and complexity; adding the Trajectory target does not make Chat scan Trajectory Contexts or Nodes. + +### Independent presentation hot paths + +The Context migration and the following presentation optimizations solve different costs. These reductions preserve the existing view model and are theoretical from call counts and asymptotic behavior; this decision does not claim benchmark measurements. + +| Hot path | Retained behavior | Expected reduction | +|---|---|---| +| Markdown summaries | Layout retains source Markdown; each stable Table record memoizes its displayed summary by content, while Detail parses only the selected record | A one-record append reparses the changed visible record instead of every Markdown record | +| Search text | `TrajectorySearchIndex` linearly checks stable Record IDs and source signatures, but normalizes Markdown only for changed records and commits updates in three-second batches | Signature comparison remains `O(C)`; expensive normalization follows the changed-record count, and continuous frame updates collapse into one batch per interval | +| Timeline tooltip | Timing text is computed after the delayed tooltip opens | A render with no open tooltip performs no per-span label formatting | +| Following Assistant lookup | One reverse pass records the next Assistant for every input position | The former repeated forward lookup falls from worst-case `O(C²)` to `O(C)` | +| Group duration | Fixed decimal grouping replaces `toLocaleString('en-US')` for the invariant English numeric shape | Complexity remains linear in Groups, but the Intl formatter leaves the repeated render path | + +Display memoization and search indexing stay separate. Search must include off-screen records and may lag live changes by the throttle interval; Table rendering must update the visible changed record immediately and must not inherit the index's commit cadence. + +## Alternatives considered + +**Keep the independent Session History fold and optimize it locally.** Rejected: caches could reduce selected hot paths, but Trajectory would still own a second Event window, pagination repair, request inspection fold, and business-correlation implementation beside Chat. + +**Reuse Chat Definitions and branch on a `target` argument in `buildViewNode()`.** Rejected: Trajectory needs different State and intermediate records, not only another React renderer. One Definition would carry both views' payloads and conditionals and would invalidate unrelated target data when either view changed. + +**Create a Trajectory-specific Assembler.** Rejected: exact-ID routing, update-before-start collection, prepend replay, Location repair, Reader dependencies, and publication cadence are not Trajectory-specific. A second engine would recreate the lifecycle duplication this change removes. + +**Add generic Surface, rewind, fanout, or settled lifecycle concepts.** Rejected: the current durable Event stream does not require a generic Surface branch, and Session or Turn boundaries are target business inputs rather than a reason to fan out one Event over every historical Context. Completion remains business State interpreted with Location closure. + +**Replace the Trajectory stages with generic Conversation Nodes.** Rejected: stages organize requests, timing, schemas, and table layout for one view. Making them engine contracts would constrain a future plain Session-log view and return view-specific composition to Client Runtime. + +**Share one Markdown cache between display and search.** Rejected: display is immediate and viewport-bound, while search covers the complete loaded record set and intentionally batches updates. A shared cache would couple correctness and scheduling across unrelated consumers. + +## Verification + +Runtime tests pin target registration, exact-ID append, update-before-start replay, prepend identity, Reader window-gap repair, Location replay, and isolation between Chat and Trajectory snapshots. + +Trajectory Definition and Builder tests pin Assistant streaming and interruption, nested Tool calls and parallel interruption, Compaction and prompt inheritance, Steering classification and Step placement, Request marker order, stable contribution replacement, and prepend expansion. Table, layout, Timeline, and search tests pin deferred Markdown work, throttled index updates, tooltip-time formatting, and stable search results across append and prepend. + +## Consequences + +Trajectory business assembly now scales with the changed page or keyed Context instead of restarting from the complete raw Event window. Target-owned Definitions can evolve independently from Chat while retaining one Session window and one set of lifecycle rules. Steering becomes a first-class Trajectory record at its actual Step position without adding steering-specific state to Session. + +The retained stage-oriented Builder still performs work proportional to materialized Trajectory contributions and may sort on publication. The search index still performs a light linear signature pass when its input layout changes. These costs are explicit target-view work, not hidden full Event refolding. + +Definition authors must provide stable protocol identities. Old Events without a required ID can disappear from the affected Trajectory business view, which is preferable to joining unrelated records or failing history load; producers that require faithful display must log the identity. + +The [Conversation assembly decision](2026-08-09-client-conversation-node-assembly.md) remains the authority for the generic Context, Reader, Location, and publication contracts. The [Trajectory ledger decision](../feature/2026-07-27-trajectory-inspection-ledger.md) remains the authority for table hierarchy, virtualization, inspector, and interaction behavior. This Note owns how Trajectory adapts those two decisions and why the adaptation does not share final Nodes with Chat. diff --git a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md new file mode 100644 index 0000000000..be3903bc62 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md @@ -0,0 +1,105 @@ +# Agent Note: Trajectory 基于注册式 Conversation Context 组装数据 + +Status: implemented + +[English](2026-08-11-trajectory-conversation-context-assembly.md) | 中文 + +## 问题 + +Trajectory 曾维护独立的 Session History 数据源,并把完整的已加载 Event 窗口折叠为 Assistant、Tool、消息、Request header 和 Compaction 状态。Chat 已经通过注册式 Conversation Definition 组装相同的 Event 族。两条链路重复实现业务关联与分页行为;即使只改变一个业务对象,Trajectory 的结构更新仍会复制或重新扫描与原始 Event 数量成正比的数据。 + +复用 Chat 的最终 Node 无法解决职责问题。Trajectory 需要请求生命周期、运行中 Assistant 状态、提示词继承、Tool schema、计时记录和 stage-oriented read model,而 Chat 不消费这些数据。共享最终 Node payload 会让两个视图都依赖双方需求的并集。 + +本次迁移还必须保留持久 steering(中途引导)分类。`user/message` 本身不说明它是开启了一个 Turn,还是从 `next-step` inbox 被领取;更早页面还可能在消息已经物化后,才补齐缺失的 inbox 前驱或 Location。 + +## 决策 + +Trajectory 针对共享的 [`ConversationNodeAssembler`](2026-08-09-client-conversation-node-assembly.md) 注册 target 自有的 Conversation Definition 和 `trajectory` View Builder。Session 只维护一份连续 Event 窗口,并通过 `Session.views` 发布 Chat 与 Trajectory 快照;它不再运行第二套 Trajectory history source 或业务 fold。 + +每个 Definition 只属于一个 target。Chat 与 Trajectory 可以识别同一持久 Event 族,但分别维护自己的 State 和最终 Node payload。它们只共享 Assembler 的精确 ID 匹配、有序 Match、Location 事实、Reader 依赖、发布调度,以及 replace/prepend/append 生命周期。 + +既有的 [Trajectory 检查记录表](../feature/2026-07-27-trajectory-inspection-ledger.md)继续作为视图模型。Trajectory Builder 把已物化的 target Node 转换为原有的 `eventNodes`、Requests、Tool schema、运行中调用和 Location map;layout、表格虚拟化、选择、Overview 与检查器行为不会成为通用 Conversation 约定。 + +### 业务 Definition + +| 业务 | Context 标识 | State 组装方式 | Trajectory contribution | +|---|---|---|---| +| `next-step` inbox | splice Event seq | 把 splice 应用到最近的前序 inbox Context | 只维护状态,不产生可见 Node | +| 用户、steering 或注入消息 | message Event seq | 读取前序 inbox State,并对持久消息分类 | Input 或 context Node | +| Assistant 与普通 Request | `turn:step` | 折叠 `step/start`、chunk、最终消息、retry 和 `step/end` | 最终 Assistant、partial Assistant 与 Request | +| 根 Tool call | root call ID | 把根 call/result 与嵌套 Code Dispatch Event 折叠为一棵调用树 | 最终或运行中的 Tool tree | +| Compaction | compaction ID | 折叠 start、summary、end 和 replacement checkpoint | Compaction Request | +| Request header | header Event seq | 读取前一个 header,保留生效提示词及真实变化 | Prompt 与 Tool-schema 来源 | +| Session 与 Turn 边界 | boundary Event seq | 保留关闭时间和错误事实 | 被中断的 Compaction 或失败的普通 Request | + +每个关联 Event 都必须直接提供相同的业务 ID。Code Dispatch 使用 `rootCallId`,Compaction 使用 compaction ID;即使某个 Definition 按 `turn:step` 关联,普通 Tool 与 retry Event 仍保留各自的协议标识。缺少必要关联 ID 的旧记录由该 Definition 忽略,不会合入 `undefined` Context,也不会导致 Session 崩溃。 + +Assistant chunk 只更新对应的 `turn:step` Context。带内容的 chunk 请求 animation-frame 发布;usage 与 finish chunk 更新 State,但不单独强制刷新一帧。最终消息、retry 或边界立即发布。已完成 Assistant State 只保留组装后的 block、计时、usage 与 retry 事实,不会把原始 chunk ledger 复制进 target snapshot。 + +### 通过前序 Context 恢复 steering + +Trajectory 从持久 inbox 历史恢复 steering,使用与 [Chat steering 决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)相同的标识规则,但不共享 Chat 的最终 Node。 + +每条目标为 `next-step` 的 `agent/inbox/spliced` Event 都会启动一个以 Event seq 标识的不可见 Context。它的 `start()` 读取最近的前序 inbox Context,应用 splice,并存储待处理标识以及累计的已领取 message ID 集合。后续用户来源的 `user/message` 读取最近的前序 inbox Context:已领取的 ID 生成 Steering Node,其余用户来源消息生成普通 User Node。 + +仍有更早历史时,Reader miss 会记录 window-gap 依赖。prepend 补齐缺失的前驱后,Assembler 按 Event 正序重放受影响的 inbox chain 与 message Context。因此,历史分页方向不会永久错误分类消息。 + +消息 Event 的 Location 会把 steering 放进所属 Step。如果已加载历史窗口缺少足够的边界 Event,无法解析该 Location,layout 就以后续 Assistant step 作为位置回退。同一个 Step 中,运行中 Request 标记排在前置 steering 输入之后,因此该标记表示由这条输入触发的模型 Request,而不会出现在输入前面。 + +### 窗口链路与复杂度 + +记 `E` 为已加载原始 Event 数,`P` 为一次新 prepend 的页面,`D` 为 Trajectory Definition 数,`C` 为已物化的 Trajectory Context contribution 数,`Mᵣ` 为一次 prepend 使其失效的 Context 所持有的 Match 总数。`D` 是较小的注册集合;流式 chunk 会聚合到同一个 Assistant Context,因此通常 `C` 明显小于 `E`。 + +| 链路 | Context 工作量 | Target snapshot 工作量 | 结果 | +|---|---|---|---| +| 初始尾页或重连 replace | 以 `O(E × D)` 匹配已加载窗口,并按 Event 正序构造 State | 构造并排序 `C` 个 contribution | 完整 replace 仍与已加载窗口成正比 | +| 更早页面 prepend | 只匹配新 Event,并只重放 Match、Location 或 Reader 答案发生变化的 Context,成本为 `O(P × D + Mᵣ)` | 从 `C` 个 contribution 重建 stage snapshot | 业务 fold 不会从头重跑全部 `E` 个 Event | +| 实时 append | 以 `O(D)` 匹配,以 `O(1)` 找到 keyed Context,并只更新对应 State | snapshot 组装前,以 `O(1)` 替换 anchor 未变的 contribution | 业务关联成本与已加载 Event 历史无关 | + +Builder 按 Context key 保存 contribution,并维护 key-to-position index。anchor 相同的内容更新会原位替换一个 contribution;新增 contribution 或 anchor 变化才会重建并排序 contribution 顺序。随后,snapshot assembly 遍历 `C` 个 contribution,用 Map 索引 Request header 与 Tool schema,并以线性游标或索引处理 Compaction boundary 与 Turn error。 + +最终 Event 和 Request 排序使单次发布的当前上界保持为 `O(C log C)`。本次迁移移除了重复反向查找和旧的原始历史 refold,但不声称端到端发布达到 `O(1)`。Chat 保持既有 keyed snapshot 行为与复杂度;增加 Trajectory target 不会让 Chat 扫描 Trajectory Context 或 Node。 + +### 独立的表现层热点优化 + +Context 迁移与下列表现层优化解决的是不同成本。这些优化保留既有视图模型;收益来自调用次数和渐进复杂度推算,本决策不声称存在 benchmark 实测结果。 + +| 热点 | 保留的行为 | 预期减少的工作 | +|---|---|---| +| Markdown 摘要 | Layout 只保留源 Markdown;每个稳定 Table record 按内容 memo 展示摘要,Detail 只解析当前选中记录 | 单条 record append 只重解析发生变化的可见记录,而非全部 Markdown record | +| 搜索文本 | `TrajectorySearchIndex` 仍线性核对稳定 Record ID 与来源签名,但只为变化的 record 标准化 Markdown,并以三秒批次提交更新 | 签名比较仍为 `O(C)`;昂贵标准化只随变化 record 数量增长,持续 frame update 每个时间窗合并成一个批次 | +| Timeline tooltip | 延迟 Tooltip 打开后才计算计时文案 | 没有打开 Tooltip 的 render 不执行逐 span label 格式化 | +| 后继 Assistant 查找 | 一次反向遍历为每个输入位置记录后续 Assistant | 原先重复向前查找的最坏复杂度从 `O(C²)` 降为 `O(C)` | +| Group duration | 以固定十进制分组替代固定英文数字形态下的 `toLocaleString('en-US')` | 复杂度仍与 Group 数线性相关,但重复 render 路径不再调用 Intl formatter | + +展示 memo 与搜索索引彼此独立。搜索必须覆盖屏幕外 record,并允许实时变化延迟一个 throttle 周期;Table 必须立即更新发生变化的可见 record,不能继承索引的提交节奏。 + +## 考虑过的替代方案 + +**保留独立 Session History fold,只做局部优化。** 不予采纳:缓存可以降低部分热点,但 Trajectory 仍会在 Chat 之外拥有第二套 Event 窗口、分页修复、request inspection fold 与业务关联实现。 + +**复用 Chat Definition,并在 `buildViewNode()` 中按 `target` 分支。** 不予采纳:Trajectory 需要不同的 State 与中间 record,不只是另一套 React renderer。单一 Definition 会携带两个视图的 payload 与条件,并在任一视图变化时让无关 target 数据失效。 + +**创建 Trajectory 专属 Assembler。** 不予采纳:精确 ID 路由、先 update 后 start 的收集、prepend replay、Location 修复、Reader 依赖与发布节奏都不是 Trajectory 特有行为。第二套引擎会重新制造本次改造要消除的生命周期重复。 + +**增加通用 Surface、rewind、fanout 或 settled 生命周期。** 不予采纳:当前持久 Event stream 不需要通用 Surface branch;Session 或 Turn boundary 是 target 业务输入,不构成把一个 Event fanout 到全部历史 Context 的理由。完成条件仍由业务 State 结合 Location closure 判断。 + +**用通用 Conversation Node 替换 Trajectory stage。** 不予采纳:stage 为单一视图组织 Request、计时、schema 和表格 layout。把它变成引擎约定会限制未来的朴素 Session-log 视图,并把视图专属组合重新放回 Client Runtime。 + +**在展示与搜索之间共享一套 Markdown cache。** 不予采纳:展示要求立即更新且受 viewport 约束,搜索则覆盖全部已加载 record,并有意批量提交更新。共享 cache 会把两个无关消费方的正确性与调度节奏耦合起来。 + +## 验证 + +Runtime 测试固定 target 注册、精确 ID append、先 update 后 start 的 replay、prepend identity、Reader window-gap 修复、Location replay,以及 Chat 与 Trajectory snapshot 隔离。 + +Trajectory Definition 与 Builder 测试固定 Assistant streaming 与 interruption、嵌套 Tool call 和并行 interruption、Compaction 与 prompt 继承、Steering 分类和 Step 位置、Request 标记顺序、稳定 contribution 替换与 prepend 扩展。Table、layout、Timeline 与搜索测试固定延迟 Markdown 工作、节流索引更新、Tooltip 展示时格式化,以及 append/prepend 期间稳定的搜索结果。 + +## 后果 + +Trajectory 业务组装的成本随变化页面或 keyed Context 增长,不再从完整原始 Event 窗口重新开始。target 自有 Definition 可以独立于 Chat 演进,同时继续共享一份 Session 窗口和一套生命周期规则。steering 会在实际所属 Step 位置成为一等 Trajectory record,不需要向 Session 增加 steering 专属状态。 + +保留的 stage-oriented Builder 仍会执行与已物化 Trajectory contribution 数量成正比的工作,并可能在发布时排序。输入 layout 变化时,搜索索引仍会执行一次轻量线性签名检查。这些成本是显式的 target view 工作,不是隐藏的完整 Event refold。 + +Definition 作者必须提供稳定的协议标识。缺少必要 ID 的旧 Event 可能不会出现在受影响的 Trajectory 业务视图中;与合并无关记录或让历史加载失败相比,这是更安全的退化方式。要求完整展示的生产方必须记录该标识。 + +[Conversation assembly 决策](2026-08-09-client-conversation-node-assembly.md)继续作为通用 Context、Reader、Location 与发布约定的真源。[Trajectory ledger 决策](../feature/2026-07-27-trajectory-inspection-ledger.md)继续负责表格层级、虚拟化、检查器和交互行为。本 Note 负责说明 Trajectory 如何适配这两项决策,以及为何该适配不与 Chat 共享最终 Node。 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index 38bd4b3d9e..af4c04bc0e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: c46b9dbc564a8c3c83792335427614c92a015fce -2026-07-27-trajectory-inspection-ledger.zh.md: 20811f7a23fe1c9ee69dce24c975f6343eaff6df +2026-07-27-trajectory-inspection-ledger.md: 74ed1f8ec6f6efcbf77e9caec7e254cb114efbd9 +2026-07-27-trajectory-inspection-ledger.zh.md: c6bb315b72b8a7274ca0e1b245cb2c5583328c8a diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index c46b9dbc56..74ed1f8ec6 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -16,7 +16,7 @@ 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. -- 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. +- Session owns one contiguous Event window, paging state, live gap repair, and reconnect rebuild. Chat and Trajectory register separate business Definitions against the shared `ConversationNodeAssembler`; Trajectory reads its target snapshot from `Session.views` and requests one older Session page when the user reaches the loaded range's top. The [Trajectory Context assembly decision](../architecture/2026-08-11-trajectory-conversation-context-assembly.md) owns its exact-ID Definitions, stage Builder, and complexity bounds. - Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Request numbering and cumulative usage cover the loaded history window and expand as older pages arrive. - Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas. - Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered content, source fields, provider/model fields, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Scrollable Summary regions keep their scrollbar thumbs transparent until hover or `focus-within`, while retaining the scrollbar reservation and scroll behavior. Images render as media rather than serialized data. 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 20811f7a23..c6bb315b72 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -16,7 +16,7 @@ Status: implemented - 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 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 历史。[Trajectory Context 组装决策](../architecture/2026-08-11-trajectory-conversation-context-assembly.md)负责其精确 ID Definition、stage Builder 与复杂度上界。 - 普通生成调用与压缩调用形成一条按时间排序的请求投影,以用途区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。请求编号和累计用量覆盖已加载的历史窗口,并随更早页面到达而扩展。 - 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 - 选择记录或请求后,Trajectory 内部会打开检查器,其标签页和概述区域随实体类型变化:Markdown 消息提供渲染内容、来源字段、提供方/模型字段和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。可滚动的概述区域默认保持滚动条滑块透明,直到悬停或 `focus-within` 时才显示,同时保留滚动条预留空间和滚动行为。图片以媒体形式渲染,而不是显示为序列化数据。 From fd5365e374115b3aa0ec51e6f14414febe9bf607 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Tue, 11 Aug 2026 10:44:09 +0800 Subject: [PATCH 213/229] chore(packages): remove empty experimental group --- ...07-28-experimental-plugin-package-group.md | 33 ----------------- ...28-experimental-plugin-package-group.zh.md | 33 ----------------- .../2026-07-29-package-regrouping.i18n.yaml | 4 +- .../2026-07-29-package-regrouping.md | 2 +- .../2026-07-29-package-regrouping.zh.md | 2 +- ...mpty-experimental-package-group.i18n.yaml} | 6 +-- ...remove-empty-experimental-package-group.md | 37 +++++++++++++++++++ ...ove-empty-experimental-package-group.zh.md | 37 +++++++++++++++++++ packages/README.i18n.yaml | 4 +- packages/README.md | 1 - packages/README.zh.md | 1 - packages/experimental/AGENTS.md | 11 ------ packages/experimental/README.i18n.yaml | 6 --- packages/experimental/README.md | 7 ---- packages/experimental/README.zh.md | 7 ---- 15 files changed, 83 insertions(+), 108 deletions(-) delete mode 100644 .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md delete mode 100644 .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md rename .agents/notes/implemented/{architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml => simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml} (52%) create mode 100644 .agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md create mode 100644 .agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md delete mode 100644 packages/experimental/AGENTS.md delete mode 100644 packages/experimental/README.i18n.yaml delete mode 100644 packages/experimental/README.md delete mode 100644 packages/experimental/README.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md deleted file mode 100644 index 1ebae5dbb1..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: Experimental and internal package group - -Status: implemented - -English | [中文](2026-07-28-experimental-plugin-package-group.zh.md) - -## Problem - -The [package hierarchy](../../../../packages/README.md) groups plugins by product role, but it cannot distinguish release packages from prototypes or internal-only packages. The team needs an obvious shared place for useful work that is not part of the official release. - -## Decision - -The subtree rules in [`packages/experimental/AGENTS.md`](../../../../packages/experimental/AGENTS.md) make `packages/experimental/<pkg>/` the required home for Cordis plugin packages whose whole public contract is experimental or internal-only. Package names remain `@deepseek-ai/dsh-<pkg>`. - -The group is the team's in-repository place to share engineering and product-manager prototypes: members can discover, run, review, and extend one another's work against the real plugin graph without implying product support. - -Official releases exclude this directory. A package enters a release only after moving to its product-role group; release packages cannot take runtime dependencies on packages here. Examples may use them, while any other runtime dependent also belongs here. Tests may use them as development dependencies. - -Experimental packages carry no stability, compatibility, migration, or support promise: they may change APIs, configuration, or data, or disappear without deprecation or migration. Internal-only packages may define narrower internal contracts but make no public release promise. Neither status relaxes engineering, security, documentation, lifecycle, testing, or snapshot requirements. - -The pending `@deepseek-ai/dsh-tui-session-changes` `/diff` viewer and `/btw` plugin are examples governed by this rule. Promotion into an official release requires explicit review of the public contract, limitations, test evidence, and a named owner accepting stable-package obligations. - -## Alternatives considered - -**Keep experimental and internal-only packages in product-role groups with README labels.** Labels are easy to miss and cannot enforce dependency boundaries. - -**Treat every package as experimental until the first tagged release.** This provides no durable incubation boundary. - -**Develop prototypes and internal packages elsewhere.** This loses the real plugin graph, examples, snapshots, and lifecycle checks needed to evaluate them. - -## Consequences - -The path makes release exclusion and dependency blast radius visible while retaining the real plugin graph for team sharing. It gives up product-role colocation and creates path churn on promotion, while the npm name remains stable. The subtree rules, repository [current-owner/current-need rule](../../../../packages/AGENTS.md), and unchanged engineering gates limit junk-drawer growth. Because official release tooling does not yet exist, contributor policy enforces the exclusion; when such tooling is added, the directory is its required exclusion boundary. diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md deleted file mode 100644 index 2d09451c50..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: 实验性与内部专用包分组 - -Status: implemented - -[English](2026-07-28-experimental-plugin-package-group.md) | 中文 - -## 问题 - -[包层级结构](../../../../packages/README.md)按产品角色对插件分组,但无法区分发布包、原型和内部专用包。团队需要一个明确的共享位置,存放不属于官方发布版本的有价值成果。 - -## 决策 - -[`packages/experimental/AGENTS.md`](../../../../packages/experimental/AGENTS.md) 中的子树规则要求所有公开约定整体处于实验状态或仅限内部使用的 Cordis 插件包位于 `packages/experimental/<pkg>/`。包名仍为 `@deepseek-ai/dsh-<pkg>`。 - -该分组供团队在仓库内共享工程人员和产品经理制作的原型:成员可以基于真实插件图发现、运行、评审并扩展彼此的原型,但这不代表产品会提供支持。 - -官方发布版本不包含此目录。包只有移入对应的产品角色分组后才会纳入发布版本;发布包不得在运行时依赖此处的包。示例可以使用这些包;其他任何运行时依赖方也必须位于此处。测试可以将它们用作开发依赖。 - -实验性包不提供稳定性、兼容性、迁移或支持保证:其 API、配置或数据可以变更,包也可以移除,均不提供弃用期或迁移路径。内部专用包可以定义范围更窄的内部约定,但不作公开发布承诺。无论哪种状态,都不降低仓库对工程、安全、文档、生命周期、测试或快照的要求。 - -尚待完成的 `@deepseek-ai/dsh-tui-session-changes` `/diff` 查看器和 `/btw` 插件都受这项规则约束。将包提升为稳定包并纳入官方发布版本,需要明确评审其公开约定、限制和测试证据,并指定一名愿意承担稳定包义务的负责人。 - -## 考虑过的替代方案 - -**将实验性和内部专用包留在产品角色分组中,并用 README 标注。** 标注容易被忽略,也无法强制执行依赖边界。 - -**首个带标签的版本发布前,将所有包都视为实验性。** 这无法提供持久的孵化边界。 - -**在其他位置开发原型和内部专用包。** 这会失去评估它们所需的真实插件图、示例、快照和生命周期检查。 - -## 后果 - -该路径明确标示不纳入发布版本的包及其依赖影响范围,同时保留供团队共享成果的真实插件图。代价是这些包无法与同产品角色的包共置,提升并纳入发布版本时还会产生路径变动,但 npm 包名保持稳定。子树规则、仓库已有的[「必须有当前负责人和实际需求」规则](../../../../packages/AGENTS.md)以及保持不变的工程门禁,可限制该分组无序膨胀。由于官方发布工具尚不存在,目前由贡献者政策执行这项排除规则;添加发布工具后,必须以该目录为排除边界。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml index c50bf478ad..f5c1e4cad3 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-package-regrouping.md -2026-07-29-package-regrouping.md: 3c37bce05bacd6af800a76ac93fb691b896a6772 -2026-07-29-package-regrouping.zh.md: 68903ff1fad6a975c4445fe8971c8fe0dd40117f +2026-07-29-package-regrouping.md: 30fc45a122263350b4a2ad1998850f631c20f9b8 +2026-07-29-package-regrouping.zh.md: a3a9a11ec71b7f894dcea7c733eb39a80b71ac50 diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md index 3c37bce05b..30fc45a122 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md @@ -58,7 +58,7 @@ The moves landed as pure `git mv` moves, so rename detection carries the history A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot fixtures, the `pnpm-workspace.yaml`/`tsdown` globs (both `packages/*/*`), or the Python runtime manifest — all reference packages by npm name. -`client/` and `host/` were out of scope and are unchanged. The `experimental/` group proposal (PR #844) is orthogonal — a release-boundary container, not a clustering decision. +`client/` and `host/` were out of scope and are unchanged. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md index 68903ff1fa..a3a9a11ec7 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md @@ -58,7 +58,7 @@ Status: implemented 组移动未触及:npm 包名、import、`cordis.yml` 配置、快照 fixture(测试前置数据)、`pnpm-workspace.yaml` 与 `tsdown` 的 glob(都是 `packages/*/*`),以及 Python 运行时 manifest(元数据清单)——它们全部按 npm 包名引用包。 -`client/` 与 `host/` 不在本次范围内,保持不变。`experimental/` 组提案(PR #844)与本案正交:它是发布边界容器,不是聚类决策。 +`client/` 与 `host/` 不在本次范围内,保持不变。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml similarity index 52% rename from .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml rename to .agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml index 43e4b3d605..945a68054c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md -2026-07-28-experimental-plugin-package-group.md: 1ebae5dbb16d4c966f94ffde69fb0cb9bc163d80 -2026-07-28-experimental-plugin-package-group.zh.md: 2d09451c5069a775906e5bc8748c334c29008164 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md +2026-08-11-remove-empty-experimental-package-group.md: e5e81e3e3763f216921b3f3b74709b64be3dee37 +2026-08-11-remove-empty-experimental-package-group.zh.md: d44d0daaf346a5fea317f8c8c6a23f26eaa3cec0 diff --git a/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md new file mode 100644 index 0000000000..e5e81e3e37 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md @@ -0,0 +1,37 @@ +# Agent Note: Remove the empty experimental package group + +Status: implemented + +English | [中文](2026-08-11-remove-empty-experimental-package-group.zh.md) + +## Problem + +The package hierarchy reserves `packages/experimental/` for prototypes and internal-only plugins, but no package has used the group. The empty group adds placement, dependency, promotion, and release rules without a current package or release mechanism that needs them. + +The original group aimed to let the team share prototypes against the real plugin graph without implying product support. That need remains possible, but it does not justify a permanent repository category before a concrete package exists. + +## Decision + +The package hierarchy has no reserved experimental or internal-only group. Packages continue to live in groups selected for their current product role. + +A concrete package that needs different release, stability, or dependency treatment requires a decision based on its actual consumers and release mechanism. That decision may reintroduce a dedicated group when it can also define and enforce the exclusion rules. + +This note consolidates and supersedes the experimental-package-group decision, whose active triplet is removed with the empty directory. + +## Alternatives considered + +**Keep the empty group.** It provides an obvious future incubation location, but it also keeps repository rules with no current owner, package, or enforcement mechanism. + +**Move the experimental rules into the general package instructions.** This preserves the policy without an empty directory, but makes every package change carry rules for a hypothetical package class. + +**Put concrete experimental packages in product-role groups with README labels.** This preserves product-role colocation, but labels alone cannot enforce release and runtime-dependency rules. A future package can evaluate this option against its actual release mechanism. + +**Treat every package as experimental until the first tagged release.** This applies a broad temporary status without providing durable treatment for packages that remain experimental after releases begin. + +**Require prototypes to stay outside the repository.** This would lose access to the real plugin graph, examples, snapshots, and lifecycle checks. Removing the reserved group does not impose that restriction; a concrete prototype can establish the placement it needs. + +## Consequences + +The hierarchy loses an unused group and its special release and dependency policy. It also gives up a predeclared location for team discovery and a ready-made promotion path. + +The first package that needs experimental or internal-only treatment must define where it lives, how releases exclude it, which runtime dependencies are allowed, and what condition promotes or removes it. A dedicated group can return when those rules have a current consumer and enforceable mechanism. diff --git a/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md new file mode 100644 index 0000000000..d44d0daaf3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 移除空的实验性包分组 + +Status: implemented + +[English](2026-08-11-remove-empty-experimental-package-group.md) | 中文 + +## 问题 + +包层级结构预留 `packages/experimental/` 用于原型和内部专用插件,但从未有包使用该分组。这个空分组添加了放置、依赖、提升和发布规则,却没有需要这些规则的现有包或发布机制。 + +原分组旨在让团队基于真实插件图共享原型,同时不暗示产品会提供支持。这项需求将来可能出现,但在具体包出现前,不足以支持一个永久的仓库类别。 + +## 决策 + +包层级结构不再预留实验性或内部专用分组。包继续按照当前产品职责放入对应分组。 + +如果具体包需要不同的发布、稳定性或依赖处理,必须根据其实际消费方和发布机制做出决策。只要该决策同时定义并强制执行排除规则,就可以重新引入专用分组。 + +本 Agent Note 整合并取代实验性包分组决策;该旧决策的活跃三文件组随空目录一并移除。 + +## 考虑过的替代方案 + +**保留空分组。** 它为未来孵化工作提供明确位置,但也会保留没有当前负责人、包或强制执行机制的仓库规则。 + +**将实验性规则移入通用包指令。** 这可以在不保留空目录的情况下延续政策,但会让每次包变更都携带针对假设包类别的规则。 + +**将具体实验性包放入产品职责分组,并用 README 标注。** 这会保持产品职责共置,但仅靠标注无法强制执行发布和运行时依赖规则。未来的包可以根据实际发布机制评估此选项。 + +**在首个带标签的版本发布前,将每个包都视为实验性。** 这会施加宽泛的临时状态,却无法为发布开始后仍处于实验状态的包提供持久处理方式。 + +**要求原型留在仓库外。** 这会失去真实插件图、示例、快照和生命周期检查。移除预留分组并不施加这项限制;具体原型可以建立自身所需的放置规则。 + +## 后果 + +包层级结构移除了未使用的分组及其特殊发布和依赖政策,同时也放弃了预先声明的团队发现位置和现成的提升路径。 + +第一个需要实验性或内部专用处理的包必须定义其存放位置、发布版本如何排除它、允许哪些运行时依赖,以及包在何种条件下获得提升或被移除。当这些规则具有当前消费方和可强制执行的机制时,可以恢复专用分组。 diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index b4c191a1d0..5f2d8b0585 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: 19d6e5ba7b554f59bd66e213f8a53389761fc735 -README.zh.md: 17f58a2922e9019af054b0dccb6c4d9199fd1a9d +README.md: eb7df95bde10dafd7afcb168d30c9dda90296687 +README.zh.md: 03cd02510267d3abdd414bed6ec1f42773d0811a diff --git a/packages/README.md b/packages/README.md index 19d6e5ba7b..eb7df95bde 100644 --- a/packages/README.md +++ b/packages/README.md @@ -52,7 +52,6 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr | [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable surface | | [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface | | [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface | -| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) leaves load | Support — example infra | | [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free | diff --git a/packages/README.zh.md b/packages/README.zh.md index 17f58a2922..03cd025102 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -52,7 +52,6 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通 | [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 | | [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定接口 | | [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定接口 | -| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 | | [`examples/`](examples/README.md) | 演示组合包(agent-spine + CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 | | [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 | | [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 | diff --git a/packages/experimental/AGENTS.md b/packages/experimental/AGENTS.md deleted file mode 100644 index ee6bf61598..0000000000 --- a/packages/experimental/AGENTS.md +++ /dev/null @@ -1,11 +0,0 @@ -# AGENTS.md — Experimental and internal packages - -These rules supplement the [package rules](../AGENTS.md). The [experimental and internal package group decision](../../.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md) owns the rationale. - -- All Cordis plugin packages whose full public contract is experimental or internal-only belong here. An experimental option inside an otherwise stable package stays in that package's product-role group. -- Use this directory to share engineering and product-manager prototypes across the team so others can discover, run, review, and extend them against the real plugin graph. -- Official releases exclude this directory. A package enters a release only after moving to its product-role group; do not add packages here to release manifests or bundles. -- Experimental packages carry no stability, compatibility, migration, or support promise. Internal-only packages may define contracts for a limited set of internal callers and callees but make no public release promise. -- Experimental or internal-only status never relaxes repository engineering, security, documentation, lifecycle, testing, or snapshot requirements. -- Release packages must not take runtime dependencies on packages here. Examples may; every other runtime dependent is also experimental or internal-only and belongs here. Tests may use them as development dependencies. -- Promotion moves a package to its product-role group without renaming its `@deepseek-ai/dsh-*` package. Require explicit review of its public contract, limitations, test evidence, and a named owner accepting stable-package obligations. diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml deleted file mode 100644 index 48cfed37ee..0000000000 --- a/packages/experimental/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/experimental/README.md -README.md: db39af8bb1b1bcfd257e16e4ad1dd112f604ffb1 -README.zh.md: fc5942190a354668164b41b99e83b52b14d88f18 diff --git a/packages/experimental/README.md b/packages/experimental/README.md deleted file mode 100644 index db39af8bb1..0000000000 --- a/packages/experimental/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# experimental/ — experimental and internal packages - -English | [中文](README.zh.md) - -This group hosts team-shared engineering and product-manager prototypes plus internal-only Cordis plugins. It is excluded from official releases; packages move to their product-role group before release. - -No packages live here yet. The [subtree rules](AGENTS.md) define the no-warranty, dependency, and promotion boundaries. diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md deleted file mode 100644 index fc5942190a..0000000000 --- a/packages/experimental/README.zh.md +++ /dev/null @@ -1,7 +0,0 @@ -# experimental/:实验性与内部专用包 - -[English](README.md) | 中文 - -该分组容纳工程人员与产品经理在团队内共享的原型,以及内部专用 Cordis 插件。该分组不纳入官方发布版本;包在发布前移入对应的产品角色分组。 - -该分组尚未包含任何包。[子树规则](AGENTS.md)界定不作保证、依赖关系和提升机制的边界。 From 94abd8631ae83d2ff1e65a422e8a59abfb7d369c Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 7 Aug 2026 16:01:41 +0800 Subject: [PATCH 214/229] fix(feedback): include session id in acknowledgement --- .../feedback/command-feedback/src/index.ts | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index 37205b76e2..8922df008e 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -8,6 +8,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' +import type { Telemetry, TelemetrySharingStatus } from '@deepseek-ai/dsh-session-telemetry' import type { Session } from '@deepseek-ai/dsh-session' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' @@ -16,6 +17,42 @@ export const inject = ['commands'] const USAGE = 'Usage: /feedback <text>' +/** Fail closed when a future sharing status reaches the sentence switch. */ +/* v8 ignore next 3 -- only the ignored default arm calls this; the closed union cannot reach it via the public API. */ +function assertNever(value: never): never { + throw new Error(`command-feedback: unsupported sharing status ${JSON.stringify(value)}`) +} + +/** The acknowledgement's sharing sentence for a disclosed policy. */ +function sharingSentence(sharing: TelemetrySharingStatus): string { + switch (sharing) { + case 'full': + return 'Session sharing is enabled.' + case 'feedback-only': + return 'Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.' + case 'disabled': + return 'Session sharing is disabled.' + /* v8 ignore next 2 -- the seam's closed union cannot reach the default; a future status must be given a sentence here. */ + default: + return assertNever(sharing) + } +} + +/** + * The sharing disclosure appended to the acknowledgement: the mounted + * backend's disclosed policy, or a "not configured" notice when no backend + * is mounted. Read through the plugin context so the command still works + * when the telemetry service is absent. + * @param telemetry - the mounted telemetry service, or undefined. + * @returns one sentence describing this session's sharing policy. + */ +function sharingDisclosure(telemetry: Telemetry | undefined): string { + if (telemetry === undefined) { + return 'Session sharing is not configured.' + } + return sharingSentence(telemetry.sharing) +} + declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** @@ -42,17 +79,20 @@ export function recordFeedback(session: Session, text: string): void { * Validate, record, and acknowledge one feedback entry. Returning an error * leaves no `feedback/record` event. * @param invocation - receiving agent, raw command input, and UI cancellation. + * @param ctx - plugin context used to read the optional telemetry service. * @returns an acknowledgement containing the receiving session and anonymous - * user ids, or a usage error when no feedback text was supplied. + * user ids plus the session-sharing disclosure, or a usage error when no + * feedback text was supplied. */ -function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { +function executeFeedbackCommand(invocation: CommandInvocation, ctx: Context): CommandResult { if (invocation.rawInput.trim().length === 0) { return { kind: 'error', text: `Feedback text is required. ${USAGE}` } } recordFeedback(invocation.agent.session, invocation.rawInput) + const telemetry = ctx.get('telemetry') return { kind: 'success', - text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}`, + text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}. ${sharingDisclosure(telemetry)}`, } } @@ -63,6 +103,6 @@ export function apply(ctx: Context): void { description: 'record feedback about this session', input: { hint: '<text>' }, recordInput: false, - handler: executeFeedbackCommand, + handler: invocation => executeFeedbackCommand(invocation, ctx), }) } From 3f9d0436eb4ec1b070ae49a651091044a80ad9d6 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Sat, 8 Aug 2026 02:27:37 +0800 Subject: [PATCH 215/229] feat(feedback): disclose session sharing in the /feedback acknowledgement The /feedback acknowledgement now echoes the receiving session id and reports the mounted telemetry backend's sharing policy: the telemetry seam exposes a backend-independent TelemetrySharingStatus through a required abstract sharing member on the Telemetry service, the OTel backend maps its mode onto it, and the command appends one policy-only sharing sentence (full / feedback-only / disabled / not configured) to the acknowledgement. The web client renders the text through the existing command row without a client change; a new assembled-browser e2e mounts the shipped telemetry row in FULL mode against a local dead endpoint and pins the shipped default sentence as a keyless golden. --- .../2026-07-28-feedback-command.i18n.yaml | 4 +- .../feature/2026-07-28-feedback-command.md | 2 +- .../feature/2026-07-28-feedback-command.zh.md | 2 +- ...knowledgement-sharing-disclosure.i18n.yaml | 6 ++ ...back-acknowledgement-sharing-disclosure.md | 27 ++++++ ...k-acknowledgement-sharing-disclosure.zh.md | 27 ++++++ apps/web/tests/feedback-command.e2e.ts | 89 +++++++++++++++++++ apps/web/tests/scaffold.ts | 15 +++- .../feedback-command/ack.expected.md | 35 ++++++++ .../snapshots/feedback-command/session.jsonl | 17 ++++ apps/web/tsconfig.json | 1 + packages/feedback/command-feedback/README.md | 16 +++- .../feedback/command-feedback/README.zh.md | 16 +++- .../feedback/command-feedback/package.json | 2 + .../feedback/command-feedback/src/index.ts | 5 ++ .../tests/command-feedback.spec.ts | 57 ++++++++++-- .../tests/loader-composition.spec.ts | 2 +- .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session/session-telemetry-otel/README.md | 2 + .../session-telemetry-otel/README.zh.md | 2 + .../session-telemetry-otel/src/index.ts | 14 +++ .../session-telemetry-otel/tests/otel.spec.ts | 25 ++++++ .../session-telemetry/README.i18n.yaml | 4 +- packages/session/session-telemetry/README.md | 6 ++ .../session/session-telemetry/README.zh.md | 8 ++ .../session/session-telemetry/src/index.ts | 18 ++++ scripts/type-equiv.manifest.json | 5 ++ tsconfig.host.json | 1 + 28 files changed, 394 insertions(+), 18 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md create mode 100644 .agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md create mode 100644 apps/web/tests/feedback-command.e2e.ts create mode 100644 apps/web/tests/snapshots/feedback-command/ack.expected.md create mode 100644 apps/web/tests/snapshots/feedback-command/session.jsonl diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml index 809e37044f..e0ba016659 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-feedback-command.md -2026-07-28-feedback-command.md: 3edb29283c289d6d006891a4c19087b01fa8166f -2026-07-28-feedback-command.zh.md: c2513d2570474cbbaf8d94f87603d8ce10d40c14 +2026-07-28-feedback-command.md: d3b2774e41a82f6edb4303280f813ddbed75ebd1 +2026-07-28-feedback-command.zh.md: 3eeef92f2ed39c9546f013f217dd7f851d30c78c diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md index 3edb29283c..d3b2774e41 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md @@ -18,7 +18,7 @@ The package declares the log-only `feedback/record { text }` session event and e `dsh-commands` still writes its `command/run` / `command/done` lifecycle pair around `/feedback`, but this command sets `recordInput: false`. Its `command/run` therefore carries the command identity and source without `args`; the feedback text exists only in `feedback/record`, while `command/done` carries the acknowledgement outcome. All three records are log-only and non-surface. Their appends enter persistence's ordinary bounded write path; nothing forces a flush, so acknowledgement reports that the feedback is in the log rather than already on disk. -Capture remains inert for the running agent and model. The optional OTel telemetry package later adds one infrastructure consumer: it observes `feedback/record` as a release trigger in `FEEDBACK_ONLY` mode and as the local-only warning trigger in `DISABLED` mode, without changing the feedback event or command path. See [Feedback-gated session telemetry](2026-08-05-feedback-gated-session-telemetry.md). +Capture remains inert for the running agent and model. The optional OTel telemetry package later adds one infrastructure consumer: it observes `feedback/record` as a release trigger in `FEEDBACK_ONLY` mode and as the local-only warning trigger in `DISABLED` mode, without changing the feedback event or command path. See [Feedback-gated session telemetry](2026-08-05-feedback-gated-session-telemetry.md) and the [acknowledgement sharing disclosure](2026-08-07-feedback-acknowledgement-sharing-disclosure.md). ### Why feedback owns an event diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md index c2513d2570..3eeef92f2e 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md @@ -18,7 +18,7 @@ Status: implemented `dsh-commands` 仍会围绕 `/feedback` 写入 `command/run` / `command/done` 生命周期配对,但该命令设置了 `recordInput: false`。因此,它的 `command/run` 携带命令标识与来源,但不携带 `args`;反馈文本只存在于 `feedback/record` 中,而 `command/done` 携带确认结果。三个记录都仅写入日志且非 surface。它们的追加会进入持久化的常规有界写入路径;没有任何环节强制 flush,因此确认文本报告的是反馈已进入日志,而非已经落盘。 -采集对正在运行的 agent(智能体)与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为仅限本地的警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)。 +采集对正在运行的 agent(智能体)与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为仅限本地的警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)与[确认文本中的共享披露](2026-08-07-feedback-acknowledgement-sharing-disclosure.md)。 ### 为何反馈拥有自己的事件 diff --git a/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.i18n.yaml new file mode 100644 index 0000000000..b5c7f142f1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md +2026-08-07-feedback-acknowledgement-sharing-disclosure.md: 1e9cd0fb95d78aff9f6434e0583154e2c3f847da +2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md: ac26b18ad523feeabc297b212210dd73eff93a0a diff --git a/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md new file mode 100644 index 0000000000..1e9cd0fb95 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md @@ -0,0 +1,27 @@ +# Agent Note: Feedback acknowledgement sharing disclosure + +Status: implemented + +English | [中文](2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md) + +## Problem + +The `/feedback` command records a log-only `feedback/record` event and acknowledges the user, but the acknowledgement carried no durable context about what happened to the session: deployments that mount session telemetry (`FULL`, `FEEDBACK_ONLY`, or `DISABLED`) had no way to tell the user whether their feedback and session left the process, and the receiving session id was not echoed. The command plugin could not read the sharing policy because the telemetry seam exposed capture only, and the OTel mode enum lived in the optional backend package. + +## Decision + +The telemetry seam (`@deepseek-ai/dsh-session-telemetry`) now owns a backend-independent sharing vocabulary: `TelemetrySharingStatus` (`full` | `feedback-only` | `disabled`) plus a required abstract `sharing` member on the `Telemetry` service class — every backend must disclose its policy, so a consumer renders "not configured" only when no telemetry service is mounted. `@deepseek-ai/dsh-session-telemetry-otel` maps its serialized `TelemetryMode` (the [feedback-gated delivery decision](2026-08-05-feedback-gated-session-telemetry.md) owns the mode semantics) onto that status in the constructor and discloses it, including in `DISABLED`. The `/feedback` handler reads the mounted service through the plugin context (`ctx.get('telemetry')`, never a declared injection, so the command loads and runs without telemetry) and appends one sharing sentence to the acknowledgement: `Feedback recorded for session {id}. <sentence>`. No service → `Session sharing is not configured.`; `disabled` → `Session sharing is disabled.`; `feedback-only` → `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`; `full` → `Session sharing is enabled.` + +The disclosure states the current sharing policy only; it never promises delivery or retention. Handoff is the backend's non-blocking enqueue and batching, retry, and loss policy stay the backend SDK's, and a later reconfiguration can change what was shared, so the sentences claim nothing about what reached a collector or about future retention. The disclosure adds no session event and never reaches the model surface; the web client renders it through the existing command row (`CommandNode` outcome text) with no client change. + +## Alternatives considered + +**A client-side status RPC and badge.** Rejected because the acknowledgement is host-produced and the web client already renders the command result text verbatim in the command row; a separate RPC would duplicate the status in a second surface and add a wire contract for a sentence. + +**Declared `telemetry` injection in `command-feedback`.** Rejected because telemetry is optional: a declared injection fails plugin load when the service is absent, while the command must work without it. The plugin reads the service with `ctx.get('telemetry')` at handler time instead. + +**OTel package owns the vocabulary.** Rejected because `command-feedback` must not depend on the optional OTel backend package. The seam owns `TelemetrySharingStatus` so any backend can disclose a policy. + +## Consequences + +The acknowledgement is user-visible: it names the receiving session and reports the current sharing policy, honest about the fire-and-forget handoff. Package tests pin the sentence for each status and for the absent-service case; the assembled-browser e2e mounts the shipped telemetry row in FULL mode against a local dead endpoint and pins the shipped default sentence (`Session sharing is enabled.`) as a golden. The seam member is required, so a mounted backend always discloses a policy and the "not configured" sentence truthfully means no telemetry service; the `/feedback` command keeps working with no telemetry mounted. A still-blank web session renders no command row, so feedback recorded before the first message gets no visible acknowledgement (documented under the package README's limitations). diff --git a/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md new file mode 100644 index 0000000000..ac26b18ad5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 反馈确认中的会话共享披露 + +Status: implemented + +[English](2026-08-07-feedback-acknowledgement-sharing-disclosure.md) | 中文 + +## 问题 + +`/feedback` 命令会记录一个仅写入日志的 `feedback/record` 事件并确认用户,但确认文本没有携带关于会话去向的持久信息:挂载了会话遥测(`FULL`、`FEEDBACK_ONLY` 或 `DISABLED`)的部署无法告知用户其反馈和会话是否离开了进程,确认文本也没有回显接收会话的 id。命令插件无法读取共享策略,因为遥测 seam 只暴露采集能力,而 OTel 模式枚举位于可选的后端包中。 + +## 决策 + +遥测 seam(`@deepseek-ai/dsh-session-telemetry`)现在拥有与后端无关的共享词汇:`TelemetrySharingStatus`(`full` | `feedback-only` | `disabled`),并在 `Telemetry` 服务类上增加一个必需的抽象 `sharing` 成员——每个后端都必须披露其策略,因此消费方只有在未挂载任何遥测服务时才渲染「未配置」。`@deepseek-ai/dsh-session-telemetry-otel` 在构造函数中把序列化的 `TelemetryMode`(模式语义由[反馈门控投递决策](2026-08-05-feedback-gated-session-telemetry.md)负责)映射到该状态并披露,包括 `DISABLED` 模式。`/feedback` 处理器通过插件上下文读取已挂载的服务(`ctx.get('telemetry')`,绝不是声明的注入,因此命令在无遥测时也能加载和运行),并在确认文本后追加一句共享披露:`Feedback recorded for session {id}. <句子>`。无服务 → `Session sharing is not configured.`;`disabled` → `Session sharing is disabled.`;`feedback-only` → `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`;`full` → `Session sharing is enabled.` + +披露只陈述当前的共享策略,绝不承诺投递或留存:交接是后端的非阻塞入队,批处理、重试与丢失策略仍归后端 SDK,且后续重新配置可能改变已共享的内容,因此句子不声称任何内容已到达采集端,也不声称未来的留存。披露不新增任何会话事件,也绝不会进入模型 surface;Web 客户端通过现有的命令行(`CommandNode` 的结果文本)原样渲染,无需客户端改动。 + +## 备选方案 + +**客户端新增状态 RPC 与徽标。** 拒绝,因为确认文本由宿主生成,Web 客户端已经在命令行中原样渲染命令结果文本;单独的 RPC 会在第二个 surface 重复该状态,并为一句文案新增线上契约。 + +**在 `command-feedback` 中声明 `telemetry` 注入。** 拒绝,因为遥测是可选的:服务缺失时声明注入会导致插件加载失败,而命令必须在无遥测时可用。插件改为在处理器执行时用 `ctx.get('telemetry')` 读取服务。 + +**由 OTel 包拥有词汇。** 拒绝,因为 `command-feedback` 不能依赖可选的 OTel 后端包。seam 拥有 `TelemetrySharingStatus`,任何后端都能披露策略。 + +## 后果 + +确认文本对用户可见:它点名接收会话并报告当前的共享策略,如实说明 fire-and-forget 交接。包级测试为每种状态以及无服务场景固定句子;组装浏览器 e2e 以 FULL 模式挂载随附的遥测行(指向本地 dead 端点),并以 golden 固定随附默认句子(`Session sharing is enabled.`)。seam 成员是必需的,因此已挂载的后端总会披露策略,「未配置」句子如实地表示没有遥测服务;`/feedback` 命令在未挂载遥测时仍能正常工作。仍为空白的新 Web 会话不渲染命令行,因此首条消息之前记录的反馈没有可见确认(已在包 README 的限制中记录)。 diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts new file mode 100644 index 0000000000..6dd8ac19a0 --- /dev/null +++ b/apps/web/tests/feedback-command.e2e.ts @@ -0,0 +1,89 @@ +// Keyless assembled-browser coverage for the /feedback command over the +// shipped Web bundles and the real host wire. The command plane settles +// without a model turn: the host appends the log-only command/run + +// feedback/record + command/done lifecycle, and the transcript renders the +// acknowledgement — the recorded session id plus the session-sharing +// disclosure — as a persistent command row. The scaffold mounts the shipped +// telemetry row in FULL mode against a local dead endpoint (no record leaves +// the process), so the golden pins the shipped default sentence +// `Session sharing is enabled.`; the per-status sentences are pinned by the +// package and OTel unit tests. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/feedback-command', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md') +const MODE = webSnapshotMode() +// Discard port: loopback listener never binds, so FULL telemetry discloses +// the shipped default policy without any record reaching a collector. +const TELEMETRY_URL = 'http://127.0.0.1:9/v1/logs' + +const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' + +describe('web e2e: /feedback command acknowledgement', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType<typeof watchConsole> + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + telemetryUrl: TELEMETRY_URL, + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), + }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + // Fresh world: connecting a workspace births the blank session whose + // live composer accepts the slash line. + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('records feedback and renders the acknowledgement with session id and sharing status', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-command')) + if (MODE !== 'record') { + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + + // First send the recorded prompt so the transcript is active — a command + // row does not render while a fresh session is still blank. + const input = page.locator('textarea').first() + await input.fill(PROMPT) + await input.press('Enter') + await scaffold.whenTurnSettled() + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + + await input.fill('/feedback the diff view is unreadable') + await input.press('Enter') + // The command plane settles without a model turn: the ack row names the + // recorded session and the mounted FULL backend's disclosure. + await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 }) + expect(await page.getByText(/Session sharing is enabled/).count()).toBe(1) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ack.expected.md']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index a93828282e..772bd4ae91 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -245,6 +245,13 @@ export interface LaunchOptions { } /** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */ welcomeNoticePending?: boolean + /** + * Mount the shipped telemetry row in FULL mode against this exporter URL + * instead of disabling it. Used to pin a real backend disclosure in + * assembled coverage; point the URL at a local dead endpoint so no record + * leaves the process. + */ + telemetryUrl?: string /** * Browse through a trusted non-loopback hostname that the browser resolves * to loopback (for example `*.localhost`). The test server stays bound to @@ -334,6 +341,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We } catch (error) { const failures: unknown[] = [error] await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError)) + restoreSkillRootEnvironment() if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed') throw error } @@ -395,8 +403,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We { id: 'session-title-llm', disabled: true }, // Fixture sessions must never leave the process: the shipped row defaults // to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL - // names in the ambient environment). - { id: 'telemetry-otel', disabled: true }, + // names in the ambient environment). A scenario that pins a real backend + // disclosure passes a local dead endpoint instead of disabling the row. + options.telemetryUrl === undefined + ? { id: 'telemetry-otel', disabled: true } + : { id: 'telemetry-otel', config: { exporter: { url: options.telemetryUrl }, shutdownTimeoutMillis: 1_000 } }, { id: 'webserver', config: { host: '127.0.0.1', port: 0 }, diff --git a/apps/web/tests/snapshots/feedback-command/ack.expected.md b/apps/web/tests/snapshots/feedback-command/ack.expected.md new file mode 100644 index 0000000000..5e6a769e74 --- /dev/null +++ b/apps/web/tests/snapshots/feedback-command/ack.expected.md @@ -0,0 +1,35 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with the single word" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Reply with the single word LIGHTHOUSE and stop. {{clock}} +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to reply with a single word. Let me comply.": + - img + - img + - text: Think The user wants me to reply with a single word. Let me comply. +- paragraph: LIGHTHOUSE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- img +- text: feedback Feedback recorded for session session-{{uuid}}. Session sharing is enabled. +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "6% of context used" +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok diff --git a/apps/web/tests/snapshots/feedback-command/session.jsonl b/apps/web/tests/snapshots/feedback-command/session.jsonl new file mode 100644 index 0000000000..d528f36c0e --- /dev/null +++ b/apps/web/tests/snapshots/feedback-command/session.jsonl @@ -0,0 +1,17 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"} +{"type":"turn/start","seq":0,"time":1785015039291,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785015039294,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785015039362,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785015039363,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785015039930,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785015039930,"data":{"turn":1,"step":1,"index":0,"dt":[162,28,1,0,0,46,1,0,0,0,11,0,0,30],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}} +{"type":"assistant/chunk","seq":21,"time":1785015040209,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":22,"time0":1785015040209,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,30,1],"texts":["L","IGH","TH","O","USE"]}} +{"type":"assistant/chunk","seq":27,"time":1785015040241,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."}}}} +{"type":"assistant/chunk","seq":28,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}} +{"type":"assistant/chunk","seq":29,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}}}} +{"type":"assistant/chunk","seq":30,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"} +{"type":"step/end","seq":32,"time":1785015040246,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":33,"time":1785015040247,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 6b3c7518de..fc1a777afc 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -64,6 +64,7 @@ "tests/agent-preset-selection.e2e.ts", "tests/agent-preset-authoring.e2e.ts", "tests/shipped-composition.e2e.ts", + "tests/feedback-command.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", "tests/produced-file-mentions.e2e.ts", diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index 52b8fb6a42..24a975476b 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -8,11 +8,24 @@ Trigger-independent session feedback plus human-facing `/feedback` capture. The | Input | Result | |---|---| -| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}` followed by `User: {userId}`. | +| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}`, `User: {userId}`, plus the session-sharing disclosure. | | `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. | Surrounding whitespace is discarded, but feedback is otherwise unparsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own event; nothing is replaced or merged. +## Session-sharing disclosure + +The acknowledgement names the receiving session id and reports how that session is shared, read from the mounted [`telemetry`](../../session/session-telemetry/README.md) service through the plugin context (`ctx.get('telemetry')`, never a declared injection). The disclosure is one sentence chosen from the backend's [`TelemetrySharingStatus`](../../session/session-telemetry/README.md): + +| Disclosed status | Acknowledgement sentence | +|---|---| +| `full` | `Session sharing is enabled.` | +| `feedback-only` | `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.` | +| `disabled` | `Session sharing is disabled.` | +| no service | `Session sharing is not configured.` | + +The disclosure states the deployment's current sharing policy only; it never promises delivery or retention. With `full` or `feedback-only`, records are handed to the backend's non-blocking enqueue and the SDK owns batching, retry, and loss policy, so the sentence claims nothing about what reached a collector; `disabled` claims nothing about future reconfiguration. The disclosure adds no event and never enters the model surface. + ## What this plugin does and does not do `recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer and starts no model work. The optional [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) consumer observes the event without changing its capture contract. @@ -56,4 +69,5 @@ Independent of the model request path. Recording appends to the session log only - **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text. - **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one. - **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`. +- **No visible acknowledgement on a fresh session** — the web transcript renders command rows only once a session is active, so `/feedback` on a still-blank session records the event but shows no acknowledgement row. Recording feedback after the first message renders normally. - **Web only among the shipped entry points** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md index ca74d53f25..12a4dcace0 100644 --- a/packages/feedback/command-feedback/README.zh.md +++ b/packages/feedback/command-feedback/README.zh.md @@ -8,11 +8,24 @@ | 输入 | 结果 | |---|---| -| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}` 确认,随后显示 `User: {userId}`。 | +| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}`、`User: {userId}` 加会话共享披露确认。 | | `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 | 前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。 +## 会话共享披露 + +确认文本会点名接收会话的 id,并报告该会话如何被共享;该信息通过插件上下文(`ctx.get('telemetry')`,绝不是声明的注入)从已挂载的 [`telemetry`](../../session/session-telemetry/README.md) 服务读取。披露是依据后端 [`TelemetrySharingStatus`](../../session/session-telemetry/README.md) 选择的一句话: + +| 披露的状态 | 确认文本中的句子 | +|---|---| +| `full` | `Session sharing is enabled.` | +| `feedback-only` | `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.` | +| `disabled` | `Session sharing is disabled.` | +| 无服务 | `Session sharing is not configured.` | + +披露只陈述部署当前的共享策略,绝不承诺投递或留存:在 `full` 或 `feedback-only` 下,记录被交给后端的非阻塞入队,批处理、重试与丢失策略归 SDK 负责,因此句子不声称任何内容已到达采集端;`disabled` 也不声称未来不会重新配置。披露不新增任何事件,也绝不会进入模型 surface。 + ## 本插件做什么、不做什么 `recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,且不启动任何模型工作。可选的 [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) 消费方会观察该事件,但不改变它的采集约定。 @@ -56,4 +69,5 @@ - **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。 - **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone,因此错误的条目会一直保留在记录中,只能由后续条目取代。 - **没有显式持久化屏障**:确认文本紧随追加而非 flush,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`。 +- **新会话上没有可见的确认**:Web 转录只在会话激活后渲染命令行,因此在仍为空白的新会话上执行 `/feedback` 会记录事件但不会显示确认行。发送首条消息后再记录反馈即可正常渲染。 - **随附的产品入口中只有 Web 使用此命令**:无头模式、ACP 自动化和 JSON-RPC 不提供命令适配器,因此 `/feedback` 在那里不可用。 diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index b557eb788b..f45d504814 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-telemetry": "workspace:^", "@deepseek-ai/dsh-user-id": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -46,6 +47,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-telemetry": "workspace:^", "@deepseek-ai/dsh-user-id": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index 8922df008e..daeee26f79 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -83,6 +83,9 @@ export function recordFeedback(session: Session, text: string): void { * @returns an acknowledgement containing the receiving session and anonymous * user ids plus the session-sharing disclosure, or a usage error when no * feedback text was supplied. + * @returns an acknowledgement containing the receiving session id and the + * session-sharing disclosure, or a usage error when no feedback text was supplied. +>>>>>>> 632abf2957 (feat(feedback): disclose session sharing in the /feedback acknowledgement) */ function executeFeedbackCommand(invocation: CommandInvocation, ctx: Context): CommandResult { if (invocation.rawInput.trim().length === 0) { @@ -93,6 +96,8 @@ function executeFeedbackCommand(invocation: CommandInvocation, ctx: Context): Co return { kind: 'success', text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}. ${sharingDisclosure(telemetry)}`, + text: `Feedback recorded for session ${invocation.agent.session.id}. ${sharingDisclosure(telemetry)}`, +>>>>>>> 632abf2957 (feat(feedback): disclose session sharing in the /feedback acknowledgement) } } diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 453d9c17fc..ca965bff0d 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -5,6 +5,7 @@ import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session' +import { Telemetry, type TelemetrySharingStatus } from '@deepseek-ai/dsh-session-telemetry' import * as commandFeedback from '@deepseek-ai/dsh-command-feedback' const { USER_ID, getOrCreateAnonymousUserId } = vi.hoisted(() => { @@ -25,6 +26,20 @@ interface Harness { readonly plugin: Awaited<ReturnType<Context['plugin']>> } +/** Minimal mounted backend disclosing one sharing policy. */ +class FakeTelemetry extends Telemetry { + override readonly sharing: TelemetrySharingStatus + + constructor(ctx: Context, config: { sharing: TelemetrySharingStatus }) { + super(ctx) + this.sharing = config.sharing + } + + emit(): void {} + + async shutdown(): Promise<void> {} +} + /** Build a live idle agent over a store-owned session, as an app's spine does. */ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } { const session = ctx.sessions.create(SessionId(id)) @@ -48,12 +63,17 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } return { agent, session } } -/** Mount the real command registry and this producer. */ -async function harness(): Promise<Harness> { +/** + * Mount the real command registry, this producer, and optionally a telemetry + * backend disclosing one sharing policy. Without `sharing`, no telemetry + * service exists and the acknowledgement reports "not configured". + */ +async function harness(sharing?: TelemetrySharingStatus): Promise<Harness> { const ctx = new Context() await ctx.plugin(CommandService) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionStore) + if (sharing !== undefined) await ctx.plugin(FakeTelemetry, { sharing }) const plugin = await ctx.plugin(commandFeedback) const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`) ctx.agents.register(agent) @@ -104,7 +124,7 @@ describe('/feedback human command', () => { const test = await harness() await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({ kind: 'success', - text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}`, + text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is not configured.`, }) expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable']) const commandRun = test.session.events.find(event => event.type === 'command/run') @@ -152,12 +172,39 @@ describe('/feedback human command', () => { test.ctx.commands.execute(test.agent, '/feedback second', signal), ]) expect(settled.map(item => item?.result)).toEqual([ - { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` }, - { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` }, + { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is not configured.` }, + { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is not configured.` }, ]) expect(feedbackTexts(test.session)).toEqual(['first', 'second']) }) + it('discloses full session sharing in the acknowledgement', async () => { + const test = await harness('full') + await expect(run(test, ' everything shared')).resolves.toEqual({ + kind: 'success', + text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is enabled.`, + }) + expect(feedbackTexts(test.session)).toEqual(['everything shared']) + }) + + it('discloses feedback-gated session sharing in the acknowledgement', async () => { + const test = await harness('feedback-only') + await expect(run(test, ' gated sharing')).resolves.toEqual({ + kind: 'success', + text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`, + }) + expect(feedbackTexts(test.session)).toEqual(['gated sharing']) + }) + + it('discloses disabled session sharing in the acknowledgement', async () => { + const test = await harness('disabled') + await expect(run(test, ' local only')).resolves.toEqual({ + kind: 'success', + text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is disabled.`, + }) + expect(feedbackTexts(test.session)).toEqual(['local only']) + }) + it('keeps every recorded event off the model surface and out of derived history', async () => { const test = await harness() await run(test, ' invisible to the model') diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 2060fe2207..e777f13124 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -93,7 +93,7 @@ describe('/feedback real Loader composition through cordis.yml', () => { const userId = getOrCreateAnonymousUserId({ env: { DSH_HOME: root } }) expect(accepted?.result).toEqual({ kind: 'success', - text: `Feedback recorded for session feedback-loader-agent\nUser: ${userId}`, + text: `Feedback recorded for session feedback-loader-agent\nUser: ${userId}. Session sharing is not configured.`, }) const rejected = await context.commands.execute(owner, '/feedback', signal) expect(rejected?.result).toEqual({ diff --git a/packages/session/session-telemetry-otel/README.i18n.yaml b/packages/session/session-telemetry-otel/README.i18n.yaml index 2897eb7dac..161f201cfb 100644 --- a/packages/session/session-telemetry-otel/README.i18n.yaml +++ b/packages/session/session-telemetry-otel/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-telemetry-otel/README.md -README.md: 585995ce409255df9608bc33b76625374bc67669 -README.zh.md: 7f0b93363fbb4aebb80f0d3cc8108e58ce3f647f +README.md: e3eae475a180419c7822d51858ae156052a663d6 +README.zh.md: cfdf36ac5783850cc5e63bbb2b622584f1064b0c diff --git a/packages/session/session-telemetry-otel/README.md b/packages/session/session-telemetry-otel/README.md index 585995ce40..e3eae475a1 100644 --- a/packages/session/session-telemetry-otel/README.md +++ b/packages/session/session-telemetry-otel/README.md @@ -29,6 +29,8 @@ Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`T Upload authorization is positive and fail-closed. An unknown direct-construction mode fails before transport configuration is read. Only `FULL` accepts direct `ctx.telemetry.emit()` calls. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability and treats only the exact `feedback/record` object already stored at `session.events[event.seq]` as consent; an independently emitted bus value is ignored. `DISABLED` never constructs the SDK pipeline, even when exporter options are present. +The mounted service discloses the resolved mode through the seam's [`TelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` property (`full` / `feedback-only` / `disabled`), so the `/feedback` acknowledgement can report whether and how the session is shared. The disclosure is set in the constructor and is independent of capture: even `DISABLED` discloses `disabled`. + `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. In uploading modes, `shutdownTimeoutMillis` is a positive finite DSH-owned outer deadline that defaults to 3000 ms, and a non-positive-integer `processor.maxExportBatchSize` also fails at plugin load because the SDK accepts it but then hangs on shutdown. Both SDK blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are SDK behavior tuned through `processor`. The backend implements no `flush()`: the batch processor owns ordinary flushing. During shutdown, OTel awaits `exporter.forceFlush()` before the processor's `exportTimeoutMillis`-bounded completion promise; if that transport promise never settles, this package abandons the wait at `shutdownTimeoutMillis`, logs the contained shutdown failure through the coordinator, and lets application teardown continue. The deadline cannot cancel the SDK transport, so records still pending then may be lost at process exit. ## What leaves the machine diff --git a/packages/session/session-telemetry-otel/README.zh.md b/packages/session/session-telemetry-otel/README.zh.md index 7f0b93363f..cfdf36ac57 100644 --- a/packages/session/session-telemetry-otel/README.zh.md +++ b/packages/session/session-telemetry-otel/README.zh.md @@ -29,6 +29,8 @@ 上传授权采用显式许可,且为 fail-closed。通过直接构造传入未知模式时,会在读取传输配置前失败。只有 `FULL` 接受对 `ctx.telemetry.emit()` 的直接调用。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力,并且仅在 `feedback/record` 对象已经存储于 `session.events[event.seq]` 且对象身份完全相同时,才将其视为同意;独立发出的总线值会被忽略。即使存在导出器选项,`DISABLED` 也绝不会构造 SDK 流水线。 +已挂载的服务通过 seam 的 [`TelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` 属性披露解析后的模式(`full` / `feedback-only` / `disabled`),因此 `/feedback` 的确认文本可以报告会话是否以及如何被共享。该披露在构造函数中设置,与采集相互独立:即使 `DISABLED` 也会披露 `disabled`。 + `exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。在上传模式中,`shutdownTimeoutMillis` 是由 DSH 管理的有限正数外层截止时间,默认值为 3000 ms;`processor.maxExportBatchSize` 不是正整数时也会在插件加载时失败,因为 SDK 会接受该值,随后却在关闭时挂起。两个 SDK 配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是通过 `processor` 调节的 SDK 行为。该后端不实现 `flush()`:常规 flush 由批处理器负责。关闭期间,OTel 会先等待 `exporter.forceFlush()`,再等待受处理器 `exportTimeoutMillis` 限制的完成 promise;如果该传输 promise 始终不结算,本包会在 `shutdownTimeoutMillis` 到期时放弃等待,通过协调器记录已隔离的关闭失败,并让应用继续拆卸。该截止时间无法取消 SDK 传输,因此届时仍待处理的记录可能在进程退出时丢失。 ## 哪些数据会离开本机 diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index 5a5102ca51..1f208394ff 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -22,6 +22,7 @@ import { type TelemetryBackend, type TelemetryRecord, type TelemetrySeverity, + type TelemetrySharingStatus, } from '@deepseek-ai/dsh-session-telemetry' import { APP_IDENTITY } from '@deepseek-ai/dsh-llm' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' @@ -71,6 +72,17 @@ function assertNever(value: never): never { throw new Error(`session-telemetry-otel: unsupported mode ${JSON.stringify(value)}`) } +/** Map the serialized mode onto the seam's backend-independent sharing vocabulary. */ +function sharingStatusFor(mode: TelemetryMode): TelemetrySharingStatus { + switch (mode) { + case TelemetryMode.FULL: return 'full' + case TelemetryMode.FEEDBACK_ONLY: return 'feedback-only' + case TelemetryMode.DISABLED: return 'disabled' + /* v8 ignore next 2 -- resolveMode already rejected unknown values before this switch; the closed enum cannot reach the default. */ + default: return assertNever(mode) + } +} + /** * Plugin configuration: one sharing policy, two verbatim SDK option objects, * and one DSH-owned shutdown bound. Uploading modes validate their endpoint @@ -139,10 +151,12 @@ export class TelemetryOtel extends Telemetry { private readonly directEmit: TelemetryBackend['emit'] private readonly provider: LoggerProvider | undefined private readonly shutdownTimeoutMillis: number + override readonly sharing: TelemetrySharingStatus constructor(ctx: Context, config: Config) { const mode = resolveMode(config.mode) super(ctx) + this.sharing = sharingStatusFor(mode) if (mode === TelemetryMode.DISABLED) { this.directEmit = DROP_RECORD this.provider = undefined diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index 3e14bf3c9d..a5bb9d06ae 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -364,6 +364,31 @@ describe('TelemetryOtel wire', () => { expect(captures).toEqual([]) }) + it('discloses the sharing policy for every mode', async () => { + const { url, captures } = await mockCollector() + + const fullCtx = new Context() + await fullCtx.plugin(SessionStore) + const full = await fullCtx.plugin(TelemetryOtel, { exporter: { url } }) + expect(fullCtx.telemetry.sharing).toBe('full') + await full.dispose() + + const gatedCtx = new Context() + await gatedCtx.plugin(SessionStore) + const gated = await gatedCtx.plugin(TelemetryOtel, { mode: TelemetryMode.FEEDBACK_ONLY, exporter: { url } }) + expect(gatedCtx.telemetry.sharing).toBe('feedback-only') + await gated.dispose() + + const disabledCtx = new Context() + await disabledCtx.plugin(SessionStore) + const disabled = await disabledCtx.plugin(TelemetryOtel, { mode: TelemetryMode.DISABLED }) + expect(disabledCtx.telemetry.sharing).toBe('disabled') + await disabled.dispose() + + // No record was emitted by any mode, so nothing reached the collector. + expect(captures).toEqual([]) + }) + it('defaults direct construction to full delivery', async () => { const { url, captures } = await mockCollector() const ctx = new Context() diff --git a/packages/session/session-telemetry/README.i18n.yaml b/packages/session/session-telemetry/README.i18n.yaml index 3d4650361f..4b3169d5fe 100644 --- a/packages/session/session-telemetry/README.i18n.yaml +++ b/packages/session/session-telemetry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-telemetry/README.md -README.md: 827554dd53a81eab5a5fd7f145df3f835db9c173 -README.zh.md: a350ea5935a2143cb0f876eeb1eb0520ffee5c53 +README.md: 707dcfcdb0c8dfbd622630351928ac43562535ec +README.zh.md: bd080adceebf83cd9e53d72a7093db376cf6cbd1 diff --git a/packages/session/session-telemetry/README.md b/packages/session/session-telemetry/README.md index 827554dd53..707dcfcdb0 100644 --- a/packages/session/session-telemetry/README.md +++ b/packages/session/session-telemetry/README.md @@ -8,6 +8,12 @@ The telemetry Service Definition declares the `TelemetryBackend` contract, and i `TelemetryBackend` has three members: `emit(record)` MUST enqueue without blocking because it runs synchronously during `session/event` or explicit canonical-log replay; optional `flush()` is a fire-and-forget hint after a turn ends, and most backends omit it and use their SDK's normal batching schedule; `shutdown()` drains queued records and resolves when the SDK stops, and disposal awaits it. An implementation that provides `flush()` must order concurrent flushes with the final `shutdown()` drain. `Telemetry` registers this API under the `telemetry` context key; each context accepts one implementation, and a duplicate load throws. A backend constructs `TelemetryCoordinator` with `live` or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its chosen trigger. +The service also carries the required [`TelemetrySharingStatus`](#the-sharing-disclosure) `sharing` member: the deployment-selected sharing policy every backend must disclose to human-facing acknowledgement surfaces (the `/feedback` command's confirmation). A consumer renders "not configured" only when no telemetry service is mounted. The seam owns the vocabulary (`full` | `feedback-only` | `disabled`) so any backend can disclose a policy without depending on the OTel package. + +## The sharing disclosure + +The acknowledgement of a recorded feedback entry reports whether and how the session is shared, read from the mounted backend's `sharing`. A backend sets the property from its deployment configuration: `full` (every event is handed over as it happens), `feedback-only` (nothing is handed over until a `feedback/record` event releases the unreleased prefix through it), or `disabled` (nothing is handed over at all). Consumers map the status onto user-facing copy; the disclosure never claims delivery — handoff is the non-blocking enqueue, and batching, retry, and loss policy stay the backend SDK's. + ## Capture points In `live` mode the coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, then hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (capture the session's `shutdown` operational record at its termination edge, then retire it), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (capture shutdown for each still-live session, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). In `on-demand` mode it registers only the dispose effect: `captureSession()` reads the canonical log through an optional inclusive sequence boundary, while flush hints and operational events remain local. diff --git a/packages/session/session-telemetry/README.zh.md b/packages/session/session-telemetry/README.zh.md index a350ea5935..bd080adcee 100644 --- a/packages/session/session-telemetry/README.zh.md +++ b/packages/session/session-telemetry/README.zh.md @@ -8,6 +8,14 @@ `TelemetryBackend` 有三个成员:`emit(record)` 必须入队且不能阻塞,因为它会在 `session/event` 或显式权威日志回放期间同步执行;可选的 `flush()` 是轮次结束后的提示,调用方不等待结果,多数后端省略它并使用 SDK 的常规批处理计划;`shutdown()` 排空已入队记录,并在 SDK 停止后结束,dispose(资源释放)会等待它。提供 `flush()` 的实现必须安排并发 flush 与 `shutdown()` 最终排空的先后顺序。`Telemetry` 将此 API 注册在 `telemetry` 上下文键下:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 捕获构造 `TelemetryCoordinator`,并在自己选择的触发器中调用 `captureSession(session, throughSeq?)`。 +该服务还携带必需的 [`TelemetrySharingStatus`](#the-sharing-disclosure) `sharing` 成员:每个后端都必须向面向用户的确认 surface(`/feedback` 命令的确认文本)披露的部署级共享策略。消费方只有在未挂载任何遥测服务时才渲染「未配置」。seam 拥有该词汇(`full` | `feedback-only` | `disabled`),因此任何后端都可以披露策略,而无需依赖 OTel 包。 + +<a id="the-sharing-disclosure"></a> + +## 共享披露 + +一条已记录的反馈条目的确认文本会报告该会话是否以及如何被共享,读取自已挂载后端的 `sharing`。后端根据其部署配置设置该属性:`full`(每个事件在发生时立即交接)、`feedback-only`(在 `feedback/record` 事件释放其之前的未释放前缀之前,不交接任何内容)或 `disabled`(完全不交接任何内容)。消费方把状态映射为面向用户的文案;披露从不声称投递——交接是非阻塞入队,批处理、重试与丢失策略仍归后端 SDK。 + ## 捕获点 在 `live` 模式中,协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏,再交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘捕获该会话的 `shutdown` 运维记录,然后将其退役)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect(捕获每个仍存活会话的 shutdown,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。在 `on-demand` 模式中,协调器只注册 dispose effect:`captureSession()` 读取权威日志,直至可选的序列号边界(含边界);flush 提示与运维事件留在本地。 diff --git a/packages/session/session-telemetry/src/index.ts b/packages/session/session-telemetry/src/index.ts index 19b58d1ee2..0900d9cdff 100644 --- a/packages/session/session-telemetry/src/index.ts +++ b/packages/session/session-telemetry/src/index.ts @@ -130,6 +130,15 @@ export interface TelemetryBackend { shutdown(): Promise<void> } +/** + * Deployment-selected session-sharing policy disclosed by a mounted + * {@link Telemetry} backend to human-facing acknowledgement surfaces (the + * `/feedback` command's confirmation text). The seam owns the vocabulary so + * any backend can disclose a policy without depending on the OTel package; + * the values mirror the OTel backend's serialized `TelemetryMode` choices. + */ +export type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' + /** * Loadable form of the backend contract: one implementation per context — * the cordis `Service` registration under the `telemetry` key throws on a @@ -141,6 +150,15 @@ export abstract class Telemetry extends Service implements TelemetryBackend { super(ctx, 'telemetry') } + /** + * Deployment-selected session-sharing policy, disclosed for acknowledgement + * surfaces that report whether recorded feedback leaves the process. Every + * backend must disclose its policy; a consumer renders "not configured" only + * when no telemetry service is mounted. The seam owns this vocabulary so the + * disclosure is backend-independent. + */ + abstract readonly sharing: TelemetrySharingStatus + /** * See {@link TelemetryBackend.emit} — that declaration is the contract's one home. * @param record - the logical record to report; owned by the backend after the call. diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1a9d998029..c1b1b2753a 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1620,6 +1620,11 @@ "symbol": "WebBootGraph", "source": "packages/client/modules/src/client/manifest.ts" }, + { + "doc": "docs/subsystems/telemetry.md", + "symbol": "TelemetrySharingStatus", + "source": "packages/session/session-telemetry/src/index.ts" + }, { "doc": "docs/subsystems/telemetry.md", "symbol": "TelemetrySeverity", diff --git a/tsconfig.host.json b/tsconfig.host.json index 32ae7df42d..12c5d365d6 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -52,6 +52,7 @@ "apps/web/tests/agent-preset-authoring.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", "apps/web/tests/goal-bar.e2e.ts", + "apps/web/tests/feedback-command.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/produced-files.e2e.ts", "apps/web/tests/produced-file-mentions.e2e.ts", From 6a6148a08c103ad321dc72012d22754465e3e830 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Mon, 10 Aug 2026 14:53:05 +0800 Subject: [PATCH 216/229] test(feedback): fix the assembled e2e record path and teardown restore The feedback-command e2e now drives the recorded prompt through a separate all-modes test that arms whenTurnSettled before sending and writes the fixture back via recordFixture in record mode; the acknowledgement golden test runs only in replay/refresh. The scaffold restores the pinned DSH_HOME on the persistence-root setup failure path, and the telemetry subsystems page links the README's sharing-disclosure anchor. --- apps/web/tests/feedback-command.e2e.ts | 28 +++++++++++----- .../feedback-command/ack.expected.md | 2 ++ docs/module-graph.i18n.yaml | 4 +-- docs/subsystems/telemetry.i18n.yaml | 4 +-- docs/subsystems/telemetry.md | 32 ++++++++++++++----- docs/subsystems/telemetry.zh.md | 32 ++++++++++++++----- .../command-feedback/README.i18n.yaml | 4 +-- 7 files changed, 76 insertions(+), 30 deletions(-) diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts index 6dd8ac19a0..577e77a97a 100644 --- a/apps/web/tests/feedback-command.e2e.ts +++ b/apps/web/tests/feedback-command.e2e.ts @@ -16,7 +16,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -56,20 +56,32 @@ describe('web e2e: /feedback command acknowledgement', () => { await scaffold?.close() }) - it('records feedback and renders the acknowledgement with session id and sharing status', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-command')) + it('drives the recorded prompt to a settled turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-drive')) if (MODE !== 'record') { + // Drift guard: the committed fixture must carry exactly the drive prompt. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) } - - // First send the recorded prompt so the transcript is active — a command - // row does not render while a fresh session is still blank. const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + // Arm the turn-boundary waiter BEFORE sending, so a burst replay cannot + // miss the turn/end that settles the recorded turn. + const settled = scaffold.whenTurnSettled() await input.fill(PROMPT) await input.press('Enter') - await scaffold.whenTurnSettled() - await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + const sessionId = await settled + if (MODE === 'record') { + await recordFixture(scaffold, sessionId, FIXTURE) + } + }, 60_000) + it.skipIf(MODE === 'record')('records feedback and renders the acknowledgement with session id and sharing status', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-command')) + // The drive test settled the recorded turn: the transcript is active (a + // command row does not render while a fresh session is still blank) and + // the replayed reply is on screen. + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + const input = page.locator('textarea').first() await input.fill('/feedback the diff view is unreadable') await input.press('Enter') // The command plane settles without a model turn: the ack row names the diff --git a/apps/web/tests/snapshots/feedback-command/ack.expected.md b/apps/web/tests/snapshots/feedback-command/ack.expected.md index 5e6a769e74..fdc43ad90d 100644 --- a/apps/web/tests/snapshots/feedback-command/ack.expected.md +++ b/apps/web/tests/snapshots/feedback-command/ack.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with the single word" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1f864cf46d..a5f2d4e167 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: 2218d79e28e835ab96abce96eaf92bbae25e2182 -module-graph.zh.md: 276b70d69c2898d74ac6897e398b02a8944fd503 +module-graph.md: 8dea030a68f5dde3ce072a8f9ae7156ad162967b +module-graph.zh.md: a611ca8300f17b19c5d4f01032dc767dd04743c1 diff --git a/docs/subsystems/telemetry.i18n.yaml b/docs/subsystems/telemetry.i18n.yaml index f5cda71a1d..5c8d376079 100644 --- a/docs/subsystems/telemetry.i18n.yaml +++ b/docs/subsystems/telemetry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/telemetry.md -telemetry.md: 5ea5c67210ce1387cbd886935e914baf7f904fbb -telemetry.zh.md: bd8fc8acc4c8522d8b1e4bc543431c0abf224411 +telemetry.md: 1b34f25361049611483ac9a10b2f90d7dac64439 +telemetry.zh.md: 9d20831d74792944f5b17e43e6ed14f02dc00275 diff --git a/docs/subsystems/telemetry.md b/docs/subsystems/telemetry.md index 5ea5c67210..1b34f25361 100644 --- a/docs/subsystems/telemetry.md +++ b/docs/subsystems/telemetry.md @@ -2,7 +2,7 @@ English | [中文](telemetry.zh.md) -Outbound session reporting is one [capability seam](../capability-seams.md): its Service Definition ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) declares the minimal backend contract, and its capture coordinator owns the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, and handoff cursor; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) uses the OpenTelemetry JS SDK's log pipeline with its configuration unchanged. This optional capability is not part of the agent loop, and nothing here reaches a model request. The harness stops after it calls `emit()`; the reporting SDK owns batching, retry, queueing, and loss policy. The [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) records that rule and the rejected alternatives. The [Service Definition README](../../packages/session/session-telemetry/README.md) defines the capture-point, cursor, and projection contracts. +Outbound session reporting is split as a [capability seam](../capability-seams.md): the Service Definition and capture coordinator ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) own the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, handoff cursor, and minimal backend contract; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) is the OpenTelemetry JS SDK's log pipeline configured verbatim. It is one optional capability, not part of the agent-loop spine, and nothing here reaches a model request. The boundary axiom — the harness's aspect ends at `emit()`; batching, retry, queueing, and loss policy belong to the reporting SDK — and the rejected alternatives are pinned in the [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md); the capture points, cursor, and projection contracts live in the [Service Definition README](../../packages/session/session-telemetry/README.md). Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -56,12 +56,28 @@ interface TelemetryRecord { Only the first `assistant/chunk` of each `(turn, step)` ships — the stream-started signal; the rest drop at capture, so `seq` gaps are routine on the wire and never a loss signal. Every other [session event](session.md) type, including plugin-merged ones the seam never heard of, passes through whole. Delivery is best-effort: the cursor marks handed-off, not delivered, records can be lost (crash, reload window) and duplicated (cursor-less re-adoption, SDK retries), so receivers dedupe ledger records on `(session.id, event.seq)`; ops records deliberately omit that identity — they are signals to alert on, not entries to sum, and tolerate duplicates instead. +## The sharing disclosure + +The seam's acknowledgement contract (owned by the [Service Definition README's sharing-disclosure section](../../packages/session/session-telemetry/README.md#the-sharing-disclosure)): every backend discloses its deployment-selected sharing policy through the required abstract `sharing` member on `ctx.telemetry`, and consumers render "not configured" only when no telemetry service is mounted. The disclosure states the current policy, never delivery or retention — handoff is the non-blocking enqueue, and batching, retry, and loss policy stay the reporting SDK's. + +```ts type-equiv +/** + * Deployment-selected session-sharing policy disclosed by a mounted + * {@link Telemetry} backend to human-facing acknowledgement surfaces (the + * `/feedback` command's confirmation text). The seam owns the vocabulary so + * any backend can disclose a policy without depending on the OTel package; + * the values mirror the OTel backend's serialized `TelemetryMode` choices. + */ +type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' +``` + ## The backend contract ```ts type-equiv /** - * The minimum backend contract the coordinator requires. {@link Telemetry} is - * its service-registered form; tests compose the coordinator with a bare + * The backend contract the coordinator hands records to — the minimum any + * reporting SDK satisfies with zero bending. {@link Telemetry} is its + * service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -76,8 +92,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a turn ended. A backend may forward it to its SDK's - * flush so records are exported after each turn. Called + * Optional hint that a natural boundary (turn end) passed — a backend may + * forward it to its SDK's flush so records land at turn boundaries. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -104,7 +120,7 @@ interface TelemetryBackend { } ``` -`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the loadable form of this contract: each context accepts one implementation and throws on a duplicate. A backend constructs `TelemetryCoordinator` in its constructor to install capture. +`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the contract's loadable form — one implementation per context, duplicate load throws — and a backend composes the seam's `TelemetryCoordinator` in its constructor to install the capture side. ## The redact waterfall: `telemetry/record` @@ -122,7 +138,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -141,7 +157,7 @@ flush?(): void abstract shutdown(): Promise<void> ``` -Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:149`](../../packages/session/session-telemetry/src/index.ts) <a id="telemetry-events"></a> diff --git a/docs/subsystems/telemetry.zh.md b/docs/subsystems/telemetry.zh.md index bd8fc8acc4..9d20831d74 100644 --- a/docs/subsystems/telemetry.zh.md +++ b/docs/subsystems/telemetry.zh.md @@ -2,7 +2,7 @@ [English](telemetry.md) | 中文 -对外会话上报是一项[能力 seam](../capability-seams.md):其 Service Definition([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.telemetry`)声明最小后端约定,其捕获协调器负责捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)和 handoff 游标;部署方加载的 Service provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))按原配置使用 OpenTelemetry JS SDK 日志流水线。这项能力可选,不属于 agent loop(智能体循环),这里也没有任何内容会进入模型请求。Harness 调用 `emit()` 后停止处理;上报 SDK 负责批处理、重试、排队和丢失策略。[复活 Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)记录了这条规则和被否决的替代方案。[Service Definition README](../../packages/session/session-telemetry/README.md) 定义捕获点、游标和投影约定。 +对外的会话上报拆分为一项[能力 seam](../capability-seams.md):Service Definition 与捕获协调器([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.telemetry`)拥有捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)、handoff 游标与最小后端约定;部署方加载的 Service provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))则是原样配置的 OpenTelemetry JS SDK 日志流水线。它是一项可选能力,不属于 agent loop(智能体循环)主干,这里也没有任何内容会进入模型请求。边界公理(harness 的职责止于 `emit()`;批处理、重试、排队与丢失策略都属于上报 SDK)连同被否决的替代方案,均已在[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)中定案;捕获点、游标与投影的约定见 [Service Definition README](../../packages/session/session-telemetry/README.md)。 源码:[`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -56,12 +56,28 @@ interface TelemetryRecord { 每个 `(turn, step)` 只发出第一条 `assistant/chunk`,即「流已开始」的信号;其余分片在捕获时丢弃,因此导出流中的 `seq` 缺口是常态,绝不是丢失信号。其他所有[会话事件](session.md)类型都会完整透传,包括该 seam 从未听说过、由插件合并进来的事件类型。投递是尽力而为的:游标标记的是「已交接」而非「已送达」,记录可能丢失(崩溃、重载窗口)也可能重复(无游标的重新接管、SDK 重试),因此接收端对 ledger 记录基于 `(session.id, event.seq)` 去重;ops 记录刻意省略这类标识——它们是用于告警的信号,而非用于累加的条目,重复被容忍而非被去重。 +## 共享披露 + +该 seam 的确认契约(归属 [Service Definition README 的共享披露段](../../packages/session/session-telemetry/README.md#the-sharing-disclosure)):每个后端都通过 `ctx.telemetry` 上必需的抽象 `sharing` 成员披露其部署级共享策略,消费方只有在未挂载任何遥测服务时才渲染「未配置」。披露只陈述当前策略,绝不承诺投递或留存——交接是非阻塞入队,批处理、重试与丢失策略仍归上报 SDK。 + +```ts type-equiv +/** + * Deployment-selected session-sharing policy disclosed by a mounted + * {@link Telemetry} backend to human-facing acknowledgement surfaces (the + * `/feedback` command's confirmation text). The seam owns the vocabulary so + * any backend can disclose a policy without depending on the OTel package; + * the values mirror the OTel backend's serialized `TelemetryMode` choices. + */ +type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' +``` + ## 后端约定 ```ts type-equiv /** - * The minimum backend contract the coordinator requires. {@link Telemetry} is - * its service-registered form; tests compose the coordinator with a bare + * The backend contract the coordinator hands records to — the minimum any + * reporting SDK satisfies with zero bending. {@link Telemetry} is its + * service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -76,8 +92,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a turn ended. A backend may forward it to its SDK's - * flush so records are exported after each turn. Called + * Optional hint that a natural boundary (turn end) passed — a backend may + * forward it to its SDK's flush so records land at turn boundaries. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -104,7 +120,7 @@ interface TelemetryBackend { } ``` -`Telemetry`(`ctx.telemetry`,[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载类型:每个上下文只允许一个实现,重复加载会抛出异常。后端在构造函数中创建 `TelemetryCoordinator`,以安装捕获处理。 +`Telemetry`(`ctx.telemetry`,[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载形态:每个上下文只允许一个实现,重复加载会抛出异常;后端在其构造函数中组合 seam 的 `TelemetryCoordinator`,以此装配捕获侧。 ## 脱敏 waterfall:`telemetry/record` @@ -122,7 +138,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -141,7 +157,7 @@ flush?(): void abstract shutdown(): Promise<void> ``` -Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:149`](../../packages/session/session-telemetry/src/index.ts) <a id="telemetry-events"></a> diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index ea0c591ae2..f199e9f4eb 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/feedback/command-feedback/README.md -README.md: 52b8fb6a423fca69f76397deec36ecd22a6a6023 -README.zh.md: ca74d53f2531a46c2c16aa1423cee52e89c8256f +README.md: 6db5bc4b18815778628d5caa74b75c8b46d3f6a8 +README.zh.md: d9ad1aba9bdbc04110430aa3e3f6604313b8ee20 From ac7c44a5dfebb7cc3f8514d780a13442c2233c55 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Mon, 10 Aug 2026 18:33:44 +0800 Subject: [PATCH 217/229] fix(feedback): drop rebase residue from the sharing acknowledgement The post-rebase cleanup removes leftover conflict-marker lines and the superseded acknowledgement text from the command source, re-adds the session-telemetry project reference, and restores the lockfile importer link for the sharing dependency. --- packages/feedback/command-feedback/src/index.ts | 5 ----- packages/feedback/command-feedback/tsconfig.json | 3 +++ pnpm-lock.yaml | 3 +++ 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index daeee26f79..8922df008e 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -83,9 +83,6 @@ export function recordFeedback(session: Session, text: string): void { * @returns an acknowledgement containing the receiving session and anonymous * user ids plus the session-sharing disclosure, or a usage error when no * feedback text was supplied. - * @returns an acknowledgement containing the receiving session id and the - * session-sharing disclosure, or a usage error when no feedback text was supplied. ->>>>>>> 632abf2957 (feat(feedback): disclose session sharing in the /feedback acknowledgement) */ function executeFeedbackCommand(invocation: CommandInvocation, ctx: Context): CommandResult { if (invocation.rawInput.trim().length === 0) { @@ -96,8 +93,6 @@ function executeFeedbackCommand(invocation: CommandInvocation, ctx: Context): Co return { kind: 'success', text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}. ${sharingDisclosure(telemetry)}`, - text: `Feedback recorded for session ${invocation.agent.session.id}. ${sharingDisclosure(telemetry)}`, ->>>>>>> 632abf2957 (feat(feedback): disclose session sharing in the /feedback acknowledgement) } } diff --git a/packages/feedback/command-feedback/tsconfig.json b/packages/feedback/command-feedback/tsconfig.json index c39f55f60f..fe189a9c3e 100644 --- a/packages/feedback/command-feedback/tsconfig.json +++ b/packages/feedback/command-feedback/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../session/user-id" }, + { + "path": "../../session/session-telemetry" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 771706fb80..a268f8e951 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3782,6 +3782,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-telemetry': + specifier: workspace:^ + version: link:../../session/session-telemetry '@deepseek-ai/dsh-user-id': specifier: workspace:^ version: link:../../session/user-id From d9f8270cc3e3ed796572851e02a133a222d292e1 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Mon, 10 Aug 2026 18:33:49 +0800 Subject: [PATCH 218/229] docs: sync sharing-disclosure catalogs and module graph after rebase Regenerates the ack golden for the merged acknowledgement format, records the zh counterparts and pairing hashes for the telemetry and catalog pages, and restores the command-feedback to session-telemetry edge and dependency in the module graph. --- .../snapshots/feedback-command/ack.expected.md | 6 ++++-- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- docs/persistence-catalog.i18n.yaml | 2 +- docs/persistence-catalog.md | 2 +- docs/subsystems/telemetry.i18n.yaml | 4 ++-- docs/subsystems/telemetry.md | 13 ++++++------- docs/subsystems/telemetry.zh.md | 13 ++++++------- packages/feedback/command-feedback/README.i18n.yaml | 4 ++-- 12 files changed, 30 insertions(+), 28 deletions(-) diff --git a/apps/web/tests/snapshots/feedback-command/ack.expected.md b/apps/web/tests/snapshots/feedback-command/ack.expected.md index fdc43ad90d..9a854ec81a 100644 --- a/apps/web/tests/snapshots/feedback-command/ack.expected.md +++ b/apps/web/tests/snapshots/feedback-command/ack.expected.md @@ -23,8 +23,10 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- img -- text: feedback Feedback recorded for session session-{{uuid}}. Session sharing is enabled. +- 'button "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled."': + - img + - img + - text: "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled." - textbox "Message the agent" - button "Commands": - img diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 3957d29f57..2f05b260a2 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: 0813c9e1f1d761b69180bc919d0629e10c7661bc +config-catalog.md: 646198cea4d30ddc799ef4886af309f376cfa9f2 config-catalog.zh.md: cda44f7904196fe2bf401fed2dc5b5e8b28ccf1c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0813c9e1f1..646198cea4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1562,7 +1562,7 @@ export enum TelemetryMode { Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/session/session-telemetry-otel/src/index.ts:79`](../packages/session/session-telemetry-otel/src/index.ts) +Source: [`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/session/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a5f2d4e167..71b5e667c7 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: 8dea030a68f5dde3ce072a8f9ae7156ad162967b -module-graph.zh.md: a611ca8300f17b19c5d4f01032dc767dd04743c1 +module-graph.md: aaa75f1578679495555f4169a5b031159a6e3cbb +module-graph.zh.md: 56f76bbac65fd485fcdbb2f9bdeedb52cecc8616 diff --git a/docs/module-graph.md b/docs/module-graph.md index 2218d79e28..cbe1a2d75e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -715,6 +715,7 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_session_telemetry pkg_command_feedback --> pkg_user_id pkg_permission --> pkg_bash pkg_permission --> pkg_commands @@ -1373,7 +1374,7 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 276b70d69c..3c7511cfd3 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -717,6 +717,7 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_session_telemetry pkg_command_feedback --> pkg_user_id pkg_permission --> pkg_bash pkg_permission --> pkg_commands @@ -1375,7 +1376,7 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ee0b5cbdd6..5778b13667 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: f44569d3bacec0a832f4b4bca6acf4abb0846a0d +persistence-catalog.md: 1b94ecc541f2b9da216a5d10e02a8a5aa46f7cfb persistence-catalog.zh.md: 21ed29a3da2587a604ec90d201030fd644fc5bd4 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index f44569d3ba..1b94ecc541 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -364,7 +364,7 @@ Source: [`packages/compact/compact/src/types.ts:33`](../packages/compact/compact 'feedback/record': { text: string } ``` -Source: [`packages/feedback/command-feedback/src/index.ts:25`](../packages/feedback/command-feedback/src/index.ts) +Source: [`packages/feedback/command-feedback/src/index.ts:62`](../packages/feedback/command-feedback/src/index.ts) ### `goal/*` diff --git a/docs/subsystems/telemetry.i18n.yaml b/docs/subsystems/telemetry.i18n.yaml index 5c8d376079..09caaa6039 100644 --- a/docs/subsystems/telemetry.i18n.yaml +++ b/docs/subsystems/telemetry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/telemetry.md -telemetry.md: 1b34f25361049611483ac9a10b2f90d7dac64439 -telemetry.zh.md: 9d20831d74792944f5b17e43e6ed14f02dc00275 +telemetry.md: 97694a9a5a209224087d0d8454d83e29ce568ea4 +telemetry.zh.md: 9e8b17f4bddb3debdf4dff9d3c3fed1296ebf3d7 diff --git a/docs/subsystems/telemetry.md b/docs/subsystems/telemetry.md index 1b34f25361..97694a9a5a 100644 --- a/docs/subsystems/telemetry.md +++ b/docs/subsystems/telemetry.md @@ -75,9 +75,8 @@ type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' ```ts type-equiv /** - * The backend contract the coordinator hands records to — the minimum any - * reporting SDK satisfies with zero bending. {@link Telemetry} is its - * service-registered form; tests compose the coordinator with a bare + * The minimum backend contract the coordinator requires. {@link Telemetry} is + * its service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -92,8 +91,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a natural boundary (turn end) passed — a backend may - * forward it to its SDK's flush so records land at turn boundaries. Called + * Optional hint that a turn ended. A backend may forward it to its SDK's + * flush so records are exported after each turn. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -138,7 +137,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -157,7 +156,7 @@ flush?(): void abstract shutdown(): Promise<void> ``` -Source: [`packages/session/session-telemetry/src/index.ts:149`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:148`](../../packages/session/session-telemetry/src/index.ts) <a id="telemetry-events"></a> diff --git a/docs/subsystems/telemetry.zh.md b/docs/subsystems/telemetry.zh.md index 9d20831d74..9e8b17f4bd 100644 --- a/docs/subsystems/telemetry.zh.md +++ b/docs/subsystems/telemetry.zh.md @@ -75,9 +75,8 @@ type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' ```ts type-equiv /** - * The backend contract the coordinator hands records to — the minimum any - * reporting SDK satisfies with zero bending. {@link Telemetry} is its - * service-registered form; tests compose the coordinator with a bare + * The minimum backend contract the coordinator requires. {@link Telemetry} is + * its service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -92,8 +91,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a natural boundary (turn end) passed — a backend may - * forward it to its SDK's flush so records land at turn boundaries. Called + * Optional hint that a turn ended. A backend may forward it to its SDK's + * flush so records are exported after each turn. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -138,7 +137,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -157,7 +156,7 @@ flush?(): void abstract shutdown(): Promise<void> ``` -Source: [`packages/session/session-telemetry/src/index.ts:149`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:148`](../../packages/session/session-telemetry/src/index.ts) <a id="telemetry-events"></a> diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index f199e9f4eb..fe49d8e490 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/feedback/command-feedback/README.md -README.md: 6db5bc4b18815778628d5caa74b75c8b46d3f6a8 -README.zh.md: d9ad1aba9bdbc04110430aa3e3f6604313b8ee20 +README.md: 24a975476b6783b439d4ec94c449f2acbe0b432f +README.zh.md: 12a4dcace001442351916b17fca0d7e2f2c76245 From 806f6d625f97662d82331f7014d3049a0eb67041 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Mon, 10 Aug 2026 23:11:29 +0800 Subject: [PATCH 219/229] fix(web): accept sharing disclosure suffix in seeded-history feedback test The /feedback acknowledgement now appends a sharing-policy sentence after the anonymous user id. The seeded-history e2e regex anchored on the end of the User line, and the golden snapshot did not include the disclosure. Update both to match the new format, and re-record the module-graph translation-pairing hash after rebasing onto master (which picked up the windows-native ACL coverage fix in #2182). --- apps/web/tests/seeded-history.e2e.ts | 4 ++-- .../tests/snapshots/seeded-history/feedback-row.expected.md | 6 +++--- docs/module-graph.i18n.yaml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index a112933f9c..9a521a9d48 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -468,9 +468,9 @@ describe('web e2e: seeded history renders through cold resume', () => { if (done?.type !== 'command/done') throw new Error('feedback command did not settle') const [sessionLine, userLine, extraLine] = done.data.text?.split('\n') ?? [] expect(sessionLine).toBe(`Feedback recorded for session ${SEED_ID}`) - expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\./i) expect(extraLine).toBeUndefined() - const userId = userLine?.slice('User: '.length) + const userId = userLine?.match(/^User: ([0-9a-f-]+)/i)?.[1] if (userId === undefined) throw new Error('feedback command omitted the user id') const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) diff --git a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md index 87b763d37c..6928b95777 100644 --- a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md @@ -38,10 +38,10 @@ - text: Context injection AGENTS.md - img - text: permission preset read-only -- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" [expanded]': +- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." [expanded]': - img - - text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" -- text: "Feedback recorded for session {{seededId}} User: {{uuid}}" + - text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." +- text: "Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." - textbox "Message the agent" - button "Commands": - img diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 71b5e667c7..91e49bbc86 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: aaa75f1578679495555f4169a5b031159a6e3cbb -module-graph.zh.md: 56f76bbac65fd485fcdbb2f9bdeedb52cecc8616 +module-graph.md: 1cc8764c34e01386c4d8ce9a66198bcfa6ece1e7 +module-graph.zh.md: 508d2f4789aa2d50b22efc35254aa20fb031d5e5 From 725f0639ef089c3360cca420159418a7968f5036 Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Tue, 11 Aug 2026 00:50:56 +0800 Subject: [PATCH 220/229] ci: retrigger after rebase onto master From 4786b3be89cde0448fd777db2593274e7d06e85c Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Tue, 11 Aug 2026 01:05:53 +0800 Subject: [PATCH 221/229] ci: trigger From 893228b19063e16f84b47d0e1a2040fc9dc1126b Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Tue, 11 Aug 2026 01:32:32 +0800 Subject: [PATCH 222/229] test(feedback): refresh ack golden for master banner locale --- apps/web/tests/snapshots/feedback-command/ack.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/feedback-command/ack.expected.md b/apps/web/tests/snapshots/feedback-command/ack.expected.md index 9a854ec81a..89d40acb3b 100644 --- a/apps/web/tests/snapshots/feedback-command/ack.expected.md +++ b/apps/web/tests/snapshots/feedback-command/ack.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with the single word" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" From bb40c2b07936ee6be4884f20f8c2093e884e1ecb Mon Sep 17 00:00:00 2001 From: Chinesezjc <jczhai@deepseek.com> Date: Tue, 11 Aug 2026 11:02:05 +0800 Subject: [PATCH 223/229] docs: re-record module-graph translation pairing after rebase --- docs/module-graph.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 91e49bbc86..e0a5822779 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: 1cc8764c34e01386c4d8ce9a66198bcfa6ece1e7 -module-graph.zh.md: 508d2f4789aa2d50b22efc35254aa20fb031d5e5 +module-graph.md: cbe1a2d75ef33c44b31ac3b84bb9a54def97d0e5 +module-graph.zh.md: 3c7511cfd391ec69548588df3ab597457a420334 From 9186824e87eb5b996add8ae6d87701f6457e5684 Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Mon, 10 Aug 2026 15:22:50 +0800 Subject: [PATCH 224/229] feat(session): refuse session logs a build cannot faithfully read Old runtimes meeting a newer session format now fail loud instead of misreading: version refusal names the direction (newer: upgrade the harness; older: no upgrade path) and points at the raw JSONL log, and an event type outside the generated known vocabulary refuses resume unless its envelope carries the new ignorable: true marker (default: required, so a forgotten marker over-refuses instead of silently resuming a gutted session). gen-persistence-catalog now also emits KNOWN_SESSION_EVENT_TYPES; SQLite stores the marker in a dedicated column (SCHEMA_VERSION 15). The versioning design (monotonic integer, n->n+1 upgrader chain, migrate-on-continue) is recorded in the session-log-version-mechanism Agent Note. --- ...10-session-log-version-mechanism.i18n.yaml | 6 + ...026-08-10-session-log-version-mechanism.md | 30 +++++ ...-08-10-session-log-version-mechanism.zh.md | 30 +++++ AGENTS.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 8 +- docs/event-producer-consumer.zh.md | 8 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 39 ++++--- docs/persistence-catalog.zh.md | 11 ++ docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 6 +- docs/subsystems/persistence.zh.md | 6 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 21 +++- docs/subsystems/session.zh.md | 21 +++- .../tests/session-format-guard.snapshot.ts | 107 ++++++++++++++++++ packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 5 +- packages/core/session/README.zh.md | 5 +- packages/core/session/src/index.ts | 5 +- .../core/session/src/known-event-types.ts | 59 ++++++++++ packages/core/session/src/types.ts | 30 ++++- packages/core/session/tests/session.spec.ts | 8 ++ .../host/apiproxy/src/api/sessions.schema.ts | 1 + .../tool-cordis/src/api-catalog.ts | 2 +- .../tests/jsonl.spec.ts | 17 +++ .../session-persistence-sqlite/src/index.ts | 27 +++-- .../session-persistence-sqlite/src/schema.ts | 7 +- .../tests/sqlite.spec.ts | 15 ++- .../session-persistence/README.i18n.yaml | 4 +- .../session/session-persistence/README.md | 2 +- .../session/session-persistence/README.zh.md | 2 +- .../session-persistence/src/coordinator.ts | 74 +++++++++++- .../session/session-persistence/src/index.ts | 1 + .../tests/coordinator-contract.ts | 58 +++++++++- scripts/gen-persistence-catalog.ts | 83 +++++++++++--- 40 files changed, 622 insertions(+), 106 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md create mode 100644 .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md create mode 100644 examples/headless-agent/tests/session-format-guard.snapshot.ts create mode 100644 packages/core/session/src/known-event-types.ts diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml new file mode 100644 index 0000000000..a5c4c2044f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md +2026-08-10-session-log-version-mechanism.md: 5358edfe15091379f5b0bbbe8e3e9d0580171c03 +2026-08-10-session-log-version-mechanism.zh.md: b790338c87c78cadda0744dc02d18a5000ffe5ff diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md new file mode 100644 index 0000000000..5358edfe15 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -0,0 +1,30 @@ +# Agent Note: Session log versioning — one integer, an upgrade chain, and a per-event ignorable marker + +Status: implemented + +English | [中文](2026-08-10-session-log-version-mechanism.zh.md) + +## Problem + +Session logs must be upgradable after release, and the runtime that ships first is the floor for every later decision: whatever refusal and degradation behavior is missing from the first released reader can never be added to the copies users already run. Release issue #1901 required at minimum that an old runtime reading a newer session format reports "unsupported" instead of misreading it. The pre-change reader did the opposite on both axes: `assertVersion` rejected any version mismatch with one direction-blind message, and the JSONL decoder passed unknown event types through untouched, so reconstruction silently skipped them — resuming a gutted session with no diagnostic at all. + +## Decision + +**One monotonic integer, no major/minor split.** Whether a version step is auto-upgradable is a property of that step — expressed by whether its upgrader exists — not something a two-level numbering scheme should promise in advance (you rarely know at design time whether the next change will turn out "major"). This matches the SQLite backend's `SCHEMA_VERSION` precedent. + +**The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers. + +**Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: convert in memory through the chain of n→n+1 upgraders for viewing; persist the converted log only when the session is actually continued (atomic temp-file replace, original kept as backup). A step whose upgrader cannot be written is left empty, which cuts off every version at or below it — those degrade to raw-text viewing. + +**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example). + +## Consequences + +What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. + +## Alternatives considered + +- **Major/minor versioning** — the "is it convertible" bit lives on each step's upgrader, and pre-committing it into a number shape invites wrong promises. +- **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption. +- **Auto-migrating on view** — rewriting the artifact on open turns a read into a destructive write: a converter bug corrupts logs at browse time, and a same-directory older runtime loses access because a newer one merely looked. +- **Per-plugin runtime registration of known event types** — would make the known set composition-dependent, so a leaner same-version composition would refuse logs a fuller one wrote. The generated repo-wide list keeps same-version reads uniform; out-of-repo plugin events are outside it by construction, and a registration surface for them is deferred until such a consumer exists. diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md new file mode 100644 index 0000000000..b790338c87 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -0,0 +1,30 @@ +# Agent Note:Session log 版本机制:单调整数、升级器链、逐事件可忽略标记 + +Status: implemented + +[English](2026-08-10-session-log-version-mechanism.md) | 中文 + +## 问题 + +Session log 在发布后必须能升级格式,而最先发布的运行时决定了此后一切的下限:第一个发布版的读取器缺少哪种拒绝和降级行为,用户手里已经装上的副本就永远补不上。发布 issue #1901 的最低要求是老运行时读到新 Session 格式时明确报不支持,而不是读错。改动前的读取器在两个方向上都做反了:`assertVersion` 对任何版本不匹配抛出同一条不区分方向的消息;JSONL 解码器把不认识的事件类型原样放行,重建时静默跳过,恢复出一个内容残缺的会话且没有任何诊断。 + +## 决定 + +**一个单调递增的整数,不分大小版本。**某一步能不能自动升级是那一步自己的属性,由它的升级器存在与否表达,不该由两级编号方案提前承诺(设计时很少能预知下一个变更算不算"大")。这与 SQLite 后端 `SCHEMA_VERSION` 的先例一致。 + +**升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。 + +**读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:查看时经 n→n+1 升级器链在内存中逐级转换;只有会话真正被继续时才把转换落盘(临时文件原子替换,原文件留备份)。写不出升级器的那一步留空,这会切断该步及更早所有版本的升级路径,它们降级为只能看原文。 + +**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。 + +## 影响 + +v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 升到 15)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建;写入侧目前不写 `ignorable`(还没有生产者需要它),`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话,预发布立场接受这一点,而且拒绝是显式的而非静默的。 + +## 曾考虑的替代方案 + +- **大小两级版本号**:能否转换这一位信息属于每一步的升级器,把它预先固化进编号形状会做出错误承诺。 +- **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。 +- **查看时自动迁移落盘**:打开即改写把读操作变成破坏性写操作,转换器的 bug 会在浏览时损坏日志,同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。 +- **插件运行时注册已知事件类型**:会让已知集依赖插件组合,同版本的精简组合会拒绝完整组合写出的日志。生成的全仓库清单保证同版本读取行为一致;仓库外插件的事件按构造就在清单之外,为它们提供注册表面推迟到真有这样的消费者时再做。 diff --git a/AGENTS.md b/AGENTS.md index adc14858d7..e8ed4bc73d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. Without a plausible relationship, an explained empty companion is correct ([package invariant rules](packages/AGENTS.md)). -- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. +- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. A `SessionEventMap` member is required-on-read by default — builds that do not know its type refuse the log unless the event carries the envelope's `ignorable: true`; only structural format changes bump `SESSION_FORMAT_VERSION` ([mechanism](.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)). - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. - **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). - **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event. diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2f05b260a2..bc972314e8 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: 646198cea4d30ddc799ef4886af309f376cfa9f2 -config-catalog.zh.md: cda44f7904196fe2bf401fed2dc5b5e8b28ccf1c +config-catalog.md: cc50a2021cfc378481dc3e830a0973773bae1e02 +config-catalog.zh.md: 5ed4f54a60130927ba710cae1f041da397c2d77a diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 646198cea4..cc50a2021c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1441,7 +1441,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session/session-persistence-sqlite/src/index.ts:67`](../packages/session/session-persistence-sqlite/src/index.ts) +Source: [`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-projection-cache` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index cda44f7904..5ed4f54a60 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1443,7 +1443,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -来源:[`packages/session/session-persistence-sqlite/src/index.ts:67`](../packages/session/session-persistence-sqlite/src/index.ts) +来源:[`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-projection-cache` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 8dd7e6321a..e40160c27b 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: 19e2e660e58101b54054091b3d55b362d25d29dc -event-producer-consumer.zh.md: d29cab8974d206b74b8869057b8efcf7542c1161 +event-producer-consumer.md: 33e3f8e67291d9f3b50d9a52fc3104e2e218d799 +event-producer-consumer.zh.md: 2f036ba0cad1d86952424c4d4969795a3862cfd7 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 19e2e660e5..33e3f8e672 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,10 +30,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:97`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:106`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index d29cab8974..2f036ba0ca 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -32,10 +32,10 @@ | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:97`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:106`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 5778b13667..12fe94e64c 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 1b94ecc541f2b9da216a5d10e02a8a5aa46f7cfb -persistence-catalog.zh.md: 21ed29a3da2587a604ec90d201030fd644fc5bd4 +persistence-catalog.md: 2b150ba09eea4365fd0559c68d6f9499ae336933 +persistence-catalog.zh.md: 0ca78a63e85705aaba9c9727c22509891670f42d diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 1b94ecc541..2b150ba09e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -63,6 +63,17 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + /** + * Marks an event a reader may safely skip when it does not recognize + * `type`. Absent means required: a reader meeting an unrecognized type + * without this marker MUST refuse to reconstruct the session instead of + * silently dropping the event, because an unrecognized required event may + * change how the rest of the log is interpreted. A writer sets `true` only + * on purely informational records whose loss cannot affect reconstruction; + * defaulting to required means a forgotten marker over-refuses (an + * inconvenience) rather than silently resuming a gutted session. + */ + ignorable?: true } & (K extends SurfaceEventType ? { /** * Seq numbers of earlier events that this event cites as sources @@ -79,7 +90,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:323`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:367`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:399`](../packages/core/session/src/types.ts) ## Events @@ -192,7 +203,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:67`](../packages/inter Types: [StreamChunk](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:261`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -208,7 +219,7 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) ### `command/*` @@ -488,7 +499,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -500,7 +511,7 @@ Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -553,7 +564,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record<string, never> ``` -Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:327`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -589,7 +600,7 @@ Source: [`packages/session/session-title-llm/src/index.ts:43`](../packages/sessi 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -598,7 +609,7 @@ Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -628,7 +639,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) ### `tool/*` @@ -645,7 +656,7 @@ Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/ Types: [CallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -714,7 +725,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) ### `turn/*` @@ -734,7 +745,7 @@ Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/ Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -748,7 +759,7 @@ Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) ### `user/*` @@ -765,7 +776,7 @@ Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 21ed29a3da..0ca78a63e8 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -65,6 +65,17 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + /** + * Marks an event a reader may safely skip when it does not recognize + * `type`. Absent means required: a reader meeting an unrecognized type + * without this marker MUST refuse to reconstruct the session instead of + * silently dropping the event, because an unrecognized required event may + * change how the rest of the log is interpreted. A writer sets `true` only + * on purely informational records whose loss cannot affect reconstruction; + * defaulting to required means a forgotten marker over-refuses (an + * inconvenience) rather than silently resuming a gutted session. + */ + ignorable?: true } & (K extends SurfaceEventType ? { /** * Seq numbers of earlier events that this event cites as sources diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 65925a1608..b500928227 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: 0266d17393d07c258036f7054a02c4ab9d3c74a2 -persistence.zh.md: ced83440160ae91ae37025d8024068fb8148b0c6 +persistence.md: de7c5c4d445986fe306a782683a8559b25677c94 +persistence.zh.md: a52506aa86418f66e6b1a372020cc316f67cc1c7 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 0266d17393..de7c5c4d44 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -87,6 +87,10 @@ interface SessionHeader { } ``` +## Format refusal — logs a build cannot faithfully read + +A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). + ## `CreateSessionOptions` — seeding and metadata Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, the `agentPreset` the agent was composed from, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. @@ -342,5 +346,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot Types: [SessionEvent](session.md) · [SessionId](core.md) -Source: [`packages/session/session-persistence/src/index.ts:72`](../../packages/session/session-persistence/src/index.ts) +Source: [`packages/session/session-persistence/src/index.ts:73`](../../packages/session/session-persistence/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index ced8344016..a52506aa86 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -87,6 +87,10 @@ interface SessionHeader { } ``` +## 格式拒绝:本构建无法可靠读取的日志 + +后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时说明本构建没有升级路径。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。设计理由与推迟建设的升级器链见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)。 + ## `CreateSessionOptions`:seed 与元数据 通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth`、该 agent 所依据组装的 `agentPreset` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 @@ -342,5 +346,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot Types: [SessionEvent](session.md) · [SessionId](core.md) -Source: [`packages/session/session-persistence/src/index.ts:72`](../../packages/session/session-persistence/src/index.ts) +Source: [`packages/session/session-persistence/src/index.ts:73`](../../packages/session/session-persistence/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 7ba2ae289d..177e33013d 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 0b78e51ebf6e2ad5c312268ad4bfb4392b0486df -session.zh.md: d1e91f684a835e08406f524efe876baa1a6a72cb +session.md: 990b249cde9f02343f2c668aee5d7c000837df56 +session.zh.md: 39e8ff1e8831fd75c8929c93e263622bb5aa6ea4 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 0b78e51ebf..990b249cde 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -215,6 +215,17 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + /** + * Marks an event a reader may safely skip when it does not recognize + * `type`. Absent means required: a reader meeting an unrecognized type + * without this marker MUST refuse to reconstruct the session instead of + * silently dropping the event, because an unrecognized required event may + * change how the rest of the log is interpreted. A writer sets `true` only + * on purely informational records whose loss cannot affect reconstruction; + * defaulting to required means a forgotten marker over-refuses (an + * inconvenience) rather than silently resuming a gutted session. + */ + ignorable?: true } & (K extends SurfaceEventType ? { /** * Seq numbers of earlier events that this event cites as sources @@ -733,7 +744,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persistence.md) · [SessionId](core.md) -Source: [`packages/core/session/src/index.ts:810`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:813`](../../packages/core/session/src/index.ts) <a id="session-events"></a> @@ -762,7 +773,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:74`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:75`](../../packages/core/session/src/index.ts) <a id="sessiondisposed--emit"></a> @@ -785,7 +796,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:84`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:85`](../../packages/core/session/src/index.ts) <a id="sessionevent--emit"></a> @@ -810,7 +821,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:97`](../../packages/core/session/src/index.ts) <a id="sessionflush--parallel"></a> @@ -832,5 +843,5 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:105`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:106`](../../packages/core/session/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d1e91f684a..39e8ff1e88 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -217,6 +217,17 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + /** + * Marks an event a reader may safely skip when it does not recognize + * `type`. Absent means required: a reader meeting an unrecognized type + * without this marker MUST refuse to reconstruct the session instead of + * silently dropping the event, because an unrecognized required event may + * change how the rest of the log is interpreted. A writer sets `true` only + * on purely informational records whose loss cannot affect reconstruction; + * defaulting to required means a forgotten marker over-refuses (an + * inconvenience) rather than silently resuming a gutted session. + */ + ignorable?: true } & (K extends SurfaceEventType ? { /** * Seq numbers of earlier events that this event cites as sources @@ -737,7 +748,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persistence.md) · [SessionId](core.md) -Source: [`packages/core/session/src/index.ts:810`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:813`](../../packages/core/session/src/index.ts) <a id="session-events"></a> @@ -766,7 +777,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:74`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:75`](../../packages/core/session/src/index.ts) <a id="sessiondisposed--emit"></a> @@ -789,7 +800,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:84`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:85`](../../packages/core/session/src/index.ts) <a id="sessionevent--emit"></a> @@ -814,7 +825,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:97`](../../packages/core/session/src/index.ts) <a id="sessionflush--parallel"></a> @@ -836,5 +847,5 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:105`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:106`](../../packages/core/session/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/examples/headless-agent/tests/session-format-guard.snapshot.ts b/examples/headless-agent/tests/session-format-guard.snapshot.ts new file mode 100644 index 0000000000..d7f327b6b6 --- /dev/null +++ b/examples/headless-agent/tests/session-format-guard.snapshot.ts @@ -0,0 +1,107 @@ +/** + * Assembled-app regression for the session-format refusal surface: resuming a + * log written by a "newer" harness (format version ahead, or an unknown + * required event type) fails loud through the real Loader composition, and the + * error the product user sees names the direction and the raw log path. + * @module session-format-guard-snapshot + */ + +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import SessionStore, { + SESSION_FORMAT_VERSION, + SessionId, + type SessionEvent, + type SessionHeader, +} from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { describe, expect, it } from 'vitest' + +const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'workspace-context-resume-snapshots/offline-edit') +const replayFixture = join(fixtureDir, 'replay.jsonl') +const configPath = fileURLToPath(new URL('../workspace-context-resume.cordis.snapshot.yml', import.meta.url)) +const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +// The resumed-agent fixture in the shared config resumes exactly this id. +const sessionId = SessionId('workspace-context-resume') + +/** Persist one session with the given header version and events, returning its log path. */ +async function seedSession(root: string, cwd: string, version: number, events: SessionEvent[]): Promise<string> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + const meta: SessionHeader = { version, id: sessionId, createdAt: 1, cwd } + try { + await ctx.sessionPersistence.create(meta) + await ctx.sessionPersistence.append(sessionId, events) + const location = ctx.sessionPersistence.locate(meta) + if (location === undefined) throw new Error('JSONL backend did not locate the seeded session') + return location.path + } finally { + await ctx.fiber.dispose() + } +} + +function closedTurn(): SessionEvent[] { + return [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] +} + +describe('session format guard through the assembled app', () => { + it('refuses to resume a newer-format log, naming the upgrade direction and the raw log path', async () => { + let sessionPath = '' + const result = await runLoaderSmoke({ + label: 'newer-format resume refusal', + tempDirPrefix: 'dsh-format-guard-version-', + binScript, + libBinScript: binScript, + configPath, + binArgs: [configPath, 'Try to resume.'], + tsconfigPath, + env: { DSH_SNAPSHOT_FILE: replayFixture }, + expectedExitCode: 1, + prepare: async (runCwd) => { + sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION + 99, closedTurn()) + }, + }) + expect(result.stderr).toContain( + `session "${sessionId}" uses log format v${SESSION_FORMAT_VERSION + 99}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it`, + ) + // macOS reports the temp dir via the /private symlink parent; assert the + // stable path suffix instead of the realpath-dependent prefix. + expect(result.stderr).toContain('(raw log: ') + expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/'))) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('refuses to resume a log with an unknown required event type', async () => { + let sessionPath = '' + const result = await runLoaderSmoke({ + label: 'unknown-event resume refusal', + tempDirPrefix: 'dsh-format-guard-event-', + binScript, + libBinScript: binScript, + configPath, + binArgs: [configPath, 'Try to resume.'], + tsconfigPath, + env: { DSH_SNAPSHOT_FILE: replayFixture }, + expectedExitCode: 1, + prepare: async (runCwd) => { + sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION, [ + ...closedTurn(), + { type: 'future/event', seq: 2, time: 3, data: { payload: 1 } } as unknown as SessionEvent, + ]) + }, + }) + expect(result.stderr).toContain( + `session "${sessionId}" contains event type "future/event" (seq 2) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`, + ) + // macOS reports the temp dir via the /private symlink parent; assert the + // stable path suffix instead of the realpath-dependent prefix. + expect(result.stderr).toContain('(raw log: ') + expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/'))) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index c5ed6a2c98..72d20a2818 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: db477d94037d3463870fc8e66ea35d5e607fb6fe -README.zh.md: 1ce1e823a7e0fdbcf7b6898764a89c52b74adf6a +README.md: 57569e9c0dbfa7cb696e3a561a9ff108c2ac981f +README.zh.md: 16629dc70c79ca838ba7088aeafcc5b38b124f87 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index db477d9403..57569e9c0d 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -76,10 +76,11 @@ Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for An interrupted live turn ends with `{ kind: 'aborted', reason: AgentCancelCause }`, preserving the typed cancellation cause in the durable transcript. Persistence imports the coarse aborted outcome from the supported older format as `{ kind: 'aborted', reason: { kind: 'legacy' } }`, because that record did not retain its caller. A turn failure carries `{ kind: 'error', error }`; crash recovery alone synthesizes `{ kind: 'interrupted' }`. -Every `SessionEvent` carries two optional top-level fields (structural metadata): +Every `SessionEvent` carries three optional top-level fields (structural metadata): - `sourceEventSeqs?: number[]` — seq numbers of earlier events cited as sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed entries behind a compaction replacement entry). On `assistant/message`, a present `[]` records a known empty provider stream, while omission means a legacy or foreign event did not record the source stream; other surface events require a non-empty list when this field is present. - `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors). +- `ignorable?: true` — marks an event a reader may safely skip when it does not recognize the type; absent means required, so an unknown-type event refuses session reconstruction ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)). ### Metadata types (`types.ts`) @@ -139,5 +140,5 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi - **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`. - **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md). -- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes and a backend rejects any other version. Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)). +- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes, and a backend refuses any other version naming the direction (newer: "written by a newer harness — upgrade"; older: no upgrade path ships yet). Unknown event types refuse the same way unless marked `ignorable` in the envelope; the versioning mechanism is the [session-log-version-mechanism note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)). - **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 1ce1e823a7..16629dc70c 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -76,10 +76,11 @@ 被中断的实时轮次以 `{ kind: 'aborted', reason: AgentCancelCause }` 结束,在持久 transcript(文本记录)中保留类型化取消原因。持久化会将受支持旧格式中的粗粒度中止结果导入为 `{ kind: 'aborted', reason: { kind: 'legacy' } }`,因为该记录没有保留调用方。轮次失败携带 `{ kind: 'error', error }`;只有崩溃恢复会合成 `{ kind: 'interrupted' }`。 -每个 `SessionEvent` 都有两个可选顶层字段(结构元数据): +每个 `SessionEvent` 都有三个可选顶层字段(结构元数据): - `sourceEventSeqs?: number[]`:被引用为来源的较早事件 seq(例如 `assistant/message` 引用的 `assistant/chunk` seq,或压缩替换条目引用的已遮蔽条目)。对于 `assistant/message`,存在的 `[]` 表示已知提供方流为空;省略则表示旧版或外部事件没有记录源流。其他 surface 事件若有此字段,则要求非空列表。 - `surfaceOp?: SurfaceOp`:事件进入 surface 的方式。非 surface 事件(边界、分片、用量、错误)不含该字段。 +- `ignorable?: true`:标记读取器在不认识事件类型时可以安全跳过该事件;缺失表示必需,不认识的事件类型会使会话重建被拒绝([机制](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md))。 ### 元数据类型(`types.ts`) @@ -139,5 +140,5 @@ - **会话分支/树**(pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。 - **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。 -- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端会拒绝其他任何版本。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。 +- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端拒绝其他任何版本并说明方向(更新的版本提示"由更新的 harness 写入,请升级";更旧的版本说明尚无升级路径)。不认识的事件类型同样被拒绝,除非信封带 `ignorable` 标记;版本机制见 [session-log 版本机制 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。 - **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index dd1f7b9175..5251ca9408 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -32,6 +32,7 @@ export type { ChunkRow, StorageRecord } from './chunk-rows.ts' export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' +export { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts' /** * Find the latest closed turn that entered at least one model step, ignoring @@ -243,6 +244,7 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe case 'data': case 'surfaceOp': case 'sourceEventSeqs': + case 'ignorable': break default: throw new Error(`seed event at index ${index} has an invalid event envelope`) @@ -254,7 +256,8 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe if (typeof type !== 'string' || typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0 || typeof time !== 'number' || !Number.isSafeInteger(time) - || event['data'] === undefined) { + || event['data'] === undefined + || (event['ignorable'] !== undefined && event['ignorable'] !== true)) { throw new Error(`seed event at index ${index} has an invalid event envelope`) } switch (type) { diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts new file mode 100644 index 0000000000..2c2b5487bb --- /dev/null +++ b/packages/core/session/src/known-event-types.ts @@ -0,0 +1,59 @@ +/** + * GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run + * `pnpm run gen-persistence-catalog` to regenerate (verified fresh by + * `pnpm run verify-persistence-catalog`, part of `doc-sync`). + * @module @deepseek-ai/dsh-session/known-event-types + */ + +/** + * Every `SessionEventMap` member declared in this repository — the event + * vocabulary this build understands. The persistence read path refuses to + * interpret a log containing a type outside this set unless the event + * carries the envelope's `ignorable` marker (see `SessionEvent.ignorable` + * in `./types.ts`): such a log was likely written by a newer harness, and + * silently skipping a required event would reconstruct a wrong session. + * Downstream (out-of-repo) plugin events are outside this list by + * construction; a registration surface for them is deferred until such a + * consumer exists. + */ +export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([ + 'agent-preset/selected', + 'agent/inbox/spliced', + 'approval/asked', + 'approval/decided', + 'approval/policy', + 'assistant/chunk', + 'assistant/message', + 'command/done', + 'command/run', + 'compact/end', + 'compact/prune', + 'compact/start', + 'compact/summary', + 'feedback/record', + 'goal/change', + 'hook/invoked', + 'hook/result', + 'llm/retry', + 'llm/retry-started', + 'permission/preset', + 'plan/mode', + 'request/context', + 'request/header', + 'sandbox/mode', + 'session/end-seed', + 'session/title', + 'session/title-llm-request', + 'step/end', + 'step/start', + 'subagent/descriptor', + 'todo/write', + 'tool/call', + 'tool/code-dispatch', + 'tool/code-dispatch-start', + 'tool/result', + 'turn/end', + 'turn/start', + 'user/message', + 'web/deepseek-search-llm-request', +]) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 35dd9d1dab..9e50c18d11 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -30,8 +30,23 @@ export function SessionId(id: string): SessionId { * and enforced by every persistence backend on load. The single source of truth for the * version — write sites and the load-time check all read it. * While the harness is unreleased it is pinned at `0`: no compatibility is - * implied, incompatible logs are rejected, and no migration is provided. A - * monotonic version policy starts with the first tagged release. + * implied, incompatible logs are rejected, and no migration is provided. + * + * The version is a single monotonic integer with no major/minor split. Whether + * a bump is needed is decided by what the WRITER emits, never by what a newer + * reader can accept: bump exactly when an older runtime could no longer handle + * a new log with full semantic correctness ("parses without error" is not + * correctness — silently skipping content that shapes reconstruction is a + * wrong read). Only structural changes reach that bar: the header shape, the + * {@link SessionEvent} envelope, core event semantics, or the surface + * mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants). + * Adding an ordinary event type does not bump — the per-event + * {@link SessionEvent.ignorable} guard covers vocabulary growth instead. When + * in doubt, bump: a near-identity upgrade step is almost free, a missed bump + * makes older runtimes read new logs wrong silently. The full mechanism + * (upgrade-step chain, in-memory view conversion, migrate-on-continue) is + * recorded in the session-log-version-mechanism Agent Note + * (`.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md`). */ export const SESSION_FORMAT_VERSION = 0 @@ -389,6 +404,17 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + /** + * Marks an event a reader may safely skip when it does not recognize + * `type`. Absent means required: a reader meeting an unrecognized type + * without this marker MUST refuse to reconstruct the session instead of + * silently dropping the event, because an unrecognized required event may + * change how the rest of the log is interpreted. A writer sets `true` only + * on purely informational records whose loss cannot affect reconstruction; + * defaulting to required means a forgotten marker over-refuses (an + * inconvenience) rather than silently resuming a gutted session. + */ + ignorable?: true } & (K extends SurfaceEventType ? { /** * Seq numbers of earlier events that this event cites as sources diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 779a24e748..7fe7ee01c7 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1090,12 +1090,20 @@ describe('Session', () => { { ...base, time: '1' }, { ...base, time: 0.5 }, { type: base.type, seq: base.seq, time: base.time }, + { ...base, ignorable: false }, + { ...base, ignorable: 'yes' }, ] for (const [index, event] of cases.entries()) { expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent])) .toThrow(/invalid event envelope/) } + + // `ignorable: true` is the one accepted marker value (unknown-type skip contract). + const marked = Session.create(SessionId('ignorable-envelope'), [ + { ...base, ignorable: true } as SessionEvent, + ]) + expect(marked.events[0]?.ignorable).toBe(true) }) }) diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 81e150bc20..5c4647769a 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -45,6 +45,7 @@ export const sessionEventSchema = z.object({ data: z.unknown(), sourceEventSeqs: z.array(z.number()).optional(), surfaceOp: z.unknown().optional(), + ignorable: z.literal(true).optional(), }) as unknown as z.ZodType<SessionEvent> /** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */ diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 29965759bc..d177f49d29 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -2633,7 +2633,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEvent', - declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', + declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n ignorable?: true;\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', }, { name: 'SessionEventMap', diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index cdb239a982..70781a5d20 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -186,6 +186,23 @@ describe('SessionPersistenceJsonl: format helpers', () => { }) await fiber.dispose() }) + + it('points a format refusal at the raw log path', async () => { + const absoluteRoot = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' }) + const m = { ...meta('newer-format', '/work'), version: 7 } + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ]) + const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toContain(`(raw log: ${rawLogPath(resolve(absoluteRoot), '/work', m.id)})`) + await fiber.dispose() + }) }) describe('SessionPersistenceJsonl: durability and crash semantics', () => { diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index ab674469d9..15cf869b69 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -28,15 +28,18 @@ import { export { SCHEMA_VERSION } from './schema.ts' /** - * Serialize an event's surface-metadata fields for SQL binding. Both fields are - * nullable TEXT columns — null when the event has no surface metadata (non-surface - * events, events written before surface support). + * Serialize an event's optional envelope fields for SQL binding. The surface + * fields are nullable TEXT columns — null when the event has no surface + * metadata (non-surface events, events written before surface support); the + * ignorable marker is a nullable INTEGER column — `1` iff the envelope carries + * `ignorable: true`. */ -function surfaceBindings(event: SessionEvent): [string | null, string | null] { +function envelopeBindings(event: SessionEvent): [string | null, string | null, number | null] { const se = event as SessionEvent<SurfaceEventType> return [ se.sourceEventSeqs ? JSON.stringify(se.sourceEventSeqs) : null, se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null, + event.ignorable === true ? 1 : null, ] } @@ -225,7 +228,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers if (row === undefined) return undefined const meta = rowToMeta(row) const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') + .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') .all(id, fromSeq) as unknown as EventRow[] signal?.throwIfAborted() const { preserved } = scanRows(eventRows, fromSeq) @@ -247,7 +250,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers const row = this.rowFor(id) if (row !== undefined) { const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq') + .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? ORDER BY seq') .all(id) as unknown as EventRow[] snapshot = { row, eventRows } } @@ -279,14 +282,14 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise<void> { await this.ready const insertEvent = this.db.prepare( - 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)', + 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', ) this.db.exec('BEGIN') try { if (!isMaterialized) this.writeRow(meta) for (const event of events) { - const [surfaceSeqs, surfaceOp] = surfaceBindings(event) - insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp) + const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event) + insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable) } this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id) this.db.exec('COMMIT') @@ -310,11 +313,11 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers } if (closers.length > 0) { const insertEvent = this.db.prepare( - 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)', + 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', ) for (const event of closers) { - const [surfaceSeqs, surfaceOp] = surfaceBindings(event) - insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp) + const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event) + insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable) } } if (tornMarker !== undefined || closers.length > 0) { diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts index c7a4de7233..c7402d7d4b 100644 --- a/packages/session/session-persistence-sqlite/src/schema.ts +++ b/packages/session/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 14 +export const SCHEMA_VERSION = 15 /** SQLite application id protecting unrelated databases from persistence writes. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 @@ -55,6 +55,8 @@ export interface EventRow { source_event_seqs: string | null /** JSON-encoded `SurfaceOp` — how the event entered the surface, or null. */ surface_op: string | null + /** `1` iff the event carries the envelope's `ignorable: true` marker, else null. */ + ignorable: number | null } /** @@ -139,6 +141,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM data TEXT NOT NULL, source_event_seqs TEXT, surface_op TEXT, + ignorable INTEGER, PRIMARY KEY (session_id, seq) ) STRICT `) @@ -203,12 +206,14 @@ export function rowToEvent(row: EventRow): SessionEvent { ...row.source_event_seqs !== null ? { sourceEventSeqs: JSON.parse(row.source_event_seqs) as number[] } : {}, ...row.surface_op !== null ? { surfaceOp: JSON.parse(row.surface_op) as SurfaceOp } : {}, } + const ignorableField = row.ignorable === 1 ? { ignorable: true as const } : {} return { type: row.type as SessionEvent['type'], seq: row.seq, time: row.time, data: JSON.parse(row.data) as SessionEvent['data'], ...surfaceFields, + ...ignorableField, } as SessionEvent } diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts index 5f8f910bde..bec3a11dad 100644 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts @@ -92,6 +92,7 @@ describe('scanRows', () => { seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data), source_event_seqs: se.sourceEventSeqs !== undefined ? JSON.stringify(se.sourceEventSeqs) : null, surface_op: se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null, + ignorable: e.ignorable === true ? 1 : null, } }) @@ -142,8 +143,8 @@ describe('scanRows', () => { it('throws on an unparsable row inside the committed region', () => { const withCorruptCommitted: EventRow[] = [ - { seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null }, // corrupt, sits before a turn/end - { seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null }, + { seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // corrupt, sits before a turn/end + { seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null, ignorable: null }, ] expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/) }) @@ -151,7 +152,7 @@ describe('scanRows', () => { it('tolerates an unparsable torn-tail row after the last turn/end', () => { const withCorruptTail: EventRow[] = [ ...rows(oneTurnLog()), - { seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null }, // torn fragment, no committed turn/end after + { seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // torn fragment, no committed turn/end after ] const { preserved, tornFrom } = scanRows(withCorruptTail) expect(preserved).toEqual(oneTurnLog()) @@ -658,7 +659,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(14) + expect(SCHEMA_VERSION).toBe(15) }) it('keeps the revision stable for an empty repair hook', async () => { @@ -857,6 +858,7 @@ describe('surface field round-trip', () => { data: JSON.stringify({ turn: 1, step: 1, content: [] }), source_event_seqs: JSON.stringify([3, 5]), surface_op: JSON.stringify('append'), + ignorable: null, } const event = rowToEvent(row) expect((event as SurfaceEvent).sourceEventSeqs).toEqual([3, 5]) @@ -869,6 +871,7 @@ describe('surface field round-trip', () => { data: JSON.stringify({ turn: 1, step: 1, content: [] }), source_event_seqs: JSON.stringify([0, 1]), surface_op: JSON.stringify({ op: 'replace', start: 0, end: 1 }), + ignorable: null, } const event = rowToEvent(row) expect((event as SurfaceEvent).sourceEventSeqs).toEqual([0, 1]) @@ -879,10 +882,10 @@ describe('surface field round-trip', () => { const rows: EventRow[] = [ { seq: 0, type: 'user/message', time: 1, data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }), - source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}' }, + source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}', ignorable: null }, { seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), - source_event_seqs: null, surface_op: null }, + source_event_seqs: null, surface_op: null, ignorable: 1 }, ] const { preserved } = scanRows(rows) expect(preserved).toHaveLength(2) diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index 15808bb5e4..edb755197c 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md -README.md: 391548b1b896dca14cbe4f4ae55cf4180c4e0ac2 -README.zh.md: 7213e1ee71ba418ffacc3685df371dcba33588a7 +README.md: 7e62360ccf47151f5c450685bfebe6e89bbf187b +README.zh.md: 3d819ef0ab4f85c83c2e640f627e36341318ac35 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 391548b1b8..7e62360ccf 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -14,7 +14,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise<void>` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `prepare(id, signal?): Promise<SessionPreparation>` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. | -| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed records, and unknown `version` reject. | +| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and malformed records reject as `SessionPersistenceCorruptionError`, while an unsupported format `version` or an event type unknown to this build (without the envelope's `ignorable` marker) refuses as `SessionFormatUnsupportedError`, naming the refusal direction and the raw log path when the backend keeps one artifact per session. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. | | `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that apply only events after a stored sequence number. | | `list(signal?): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 7213e1ee71..3d819ef0ab 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -14,7 +14,7 @@ | `create(meta): Promise<void>` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | | `append(id, events): Promise<void>` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | | `prepare(id, signal?): Promise<SessionPreparation>` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose 时将未发布 reservation 释放回有界缓存。 | -| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的记录和未知 `version` 会被拒绝。 | +| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏和格式错误的记录以 `SessionPersistenceCorruptionError` 拒绝,不支持的格式 `version` 或本构建不认识且信封未带 `ignorable` 标记的事件类型以 `SessionFormatUnsupportedError` 拒绝,消息说明拒绝方向,并在后端为每个会话保留独立文件时给出原始日志路径。 | | `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 | | `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。供 checkpoint 消费方只应用已存序号之后的事件。 | | `list(signal?): Promise<SessionHeader[]>` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index be1edf01c3..6049868f98 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import { adoptSessionEvent, interruptedTurnClosers, + KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, SessionPreparation, snapshotJsonValue, @@ -16,7 +17,7 @@ import { } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import type { SessionInspection } from './index.ts' +import type { SessionInspection, SessionLocation } from './index.ts' import type { SessionPersistenceRevision } from './revision.ts' import { observeQueuedAbort, SessionPreparations } from './preparations.ts' import type { SessionPreparationReservation } from './preparations.ts' @@ -43,6 +44,26 @@ export class SessionPersistenceCorruptionError extends Error { } } +/** + * The stored log is intact but this runtime cannot faithfully interpret it: + * the header carries an unsupported format version, or an event's type is + * unknown to this build and the event is not marked ignorable. Distinct from + * {@link SessionPersistenceCorruptionError} — nothing is damaged; the raw log + * remains readable at {@link location} when the backend keeps one artifact + * per session. + */ +export class SessionFormatUnsupportedError extends Error { + /** + * @param message - stable reason the log cannot be interpreted, already + * including the raw-log path when one exists. + * @param location - the backend's artifact location, when one exists. + */ + constructor(message: string, readonly location?: SessionLocation) { + super(message) + this.name = 'SessionFormatUnsupportedError' + } +} + /** Coordinator policy supplied by a concrete persistence backend. */ export interface PersistenceCoordinatorOptions { /** Maximum completed unpublished preparations retained for reuse. */ @@ -156,6 +177,14 @@ export interface PersistenceBackend<TornMarker = unknown> { */ list(signal?: AbortSignal): Promise<SessionHeader[]> + /** + * Optional side-effect-free artifact locator, used to point refusal + * diagnostics ({@link SessionFormatUnsupportedError}) at the raw log. + * Backends without one artifact per session omit it or return `undefined`. + * @param meta - the header whose artifact is requested. + */ + locate?(meta: SessionHeader): SessionLocation | undefined + /** * Optional lifecycle teardown (e.g. close a database handle). Awaited by the * coordinator's dispose effect AFTER the quiescence drain. A stateless file @@ -806,7 +835,9 @@ export class PersistenceCoordinator<TornMarker = unknown> { const whole = await this.readStoredPrefix(id, signal) return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } } - return { meta: structuredClone(suffix.meta), events: snapshotStoredEvents(suffix.events, id) } + const events = snapshotStoredEvents(suffix.events, id) + this.assertEventsSupported(suffix.meta, events) + return { meta: structuredClone(suffix.meta), events } } const whole = await this.readStoredPrefix(id, signal) // Sequential fallback: contiguous seqs from 0 make the suffix an index slice. @@ -824,9 +855,11 @@ export class PersistenceCoordinator<TornMarker = unknown> { if (stored === undefined) throw new Error(`session "${id}" not found`) this.assertStoredId(id, stored.meta) this.assertVersion(stored.meta) + const events = snapshotStoredEvents(stored.events, id) + this.assertEventsSupported(stored.meta, events) return { meta: structuredClone(stored.meta), - events: snapshotStoredEvents(stored.events, id), + events, } } @@ -839,6 +872,7 @@ export class PersistenceCoordinator<TornMarker = unknown> { this.assertStoredId(id, meta) this.assertVersion(meta) const storedEvents = adoptStoredEvents(events, id) + this.assertEventsSupported(meta, storedEvents) // Preserve complete interrupted events and synthesize only missing closers. const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent) @@ -861,6 +895,9 @@ export class PersistenceCoordinator<TornMarker = unknown> { closers, } } catch (error: unknown) { + // An unsupported format is a refusal over an intact log, not damage — + // surface it unwrapped so callers can point at the raw artifact. + if (error instanceof SessionFormatUnsupportedError) throw error throw new SessionPersistenceCorruptionError( `stored session "${id}" failed validation: ${String(error)}`, { cause: error }, @@ -982,11 +1019,38 @@ export class PersistenceCoordinator<TornMarker = unknown> { } private assertVersion(meta: SessionHeader): void { - if (meta.version !== SESSION_FORMAT_VERSION) { - throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v${SESSION_FORMAT_VERSION} is supported)`) + if (meta.version === SESSION_FORMAT_VERSION) return + throw this.unsupported(meta, meta.version > SESSION_FORMAT_VERSION + ? `session "${meta.id}" uses log format v${meta.version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it` + : `session "${meta.id}" uses log format v${meta.version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it`) + } + + /** + * Refuse a log containing an event type this build does not know, unless the + * writer marked the event ignorable: an unrecognized required event may + * change how the rest of the log must be interpreted, so silently skipping + * it would reconstruct a wrong session (the envelope contract on + * `SessionEvent.ignorable`). Runs on NORMALIZED events — after + * `snapshotStoredEvents`/`adoptStoredEvents` has upgraded the legacy shapes + * this build still reads and rejected the ones it does not, so those keep + * their specific diagnostics. + */ + private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void { + for (const event of events) { + if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue + throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`) } } + /** Build a format refusal that points at the raw artifact when the backend has one. */ + private unsupported(meta: SessionHeader, reason: string): SessionFormatUnsupportedError { + const location = this.backend.locate?.(meta) + return new SessionFormatUnsupportedError( + location === undefined ? reason : `${reason} (raw log: ${location.path})`, + location, + ) + } + /** Reject backend metadata that is not bound to the requested session id. */ private assertStoredId(id: SessionId, meta: SessionHeader): void { if (meta.id !== id) { diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index dc06517367..62941477ff 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -36,6 +36,7 @@ export { DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, PersistenceCoordinator, + SessionFormatUnsupportedError, SessionPersistenceCorruptionError, } from './coordinator.ts' export type { diff --git a/packages/session/session-persistence/tests/coordinator-contract.ts b/packages/session/session-persistence/tests/coordinator-contract.ts index c272109f82..8633faa886 100644 --- a/packages/session/session-persistence/tests/coordinator-contract.ts +++ b/packages/session/session-persistence/tests/coordinator-contract.ts @@ -706,6 +706,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< .rejects.toThrow('lacks an identified message') } + // An out-of-repo event type passes only with the envelope's ignorable + // marker (unknown-type refusal otherwise), and its non-object data is + // not message-validated. const pluginId = SessionId('non-object-plugin-event') await ctx.sessionPersistence.create(meta(pluginId, WORK)) await ctx.sessionPersistence.append(pluginId, [{ @@ -713,11 +716,12 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< seq: 0, time: 1, data: null, + ignorable: true, } as unknown as SessionEvent]) await expect(ctx.sessionPersistence.inspect(pluginId)) - .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null }] }) + .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] }) await expect(ctx.sessionPersistence.readFrom(pluginId, 0)) - .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null }] }) + .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] }) for (const type of ['user/message', 'assistant/message'] as const) { const missingContentId = SessionId(`invalid-${type}-without-content`) @@ -1321,14 +1325,60 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) - it('rejects an unknown format version on load (assertVersion)', async () => { + it('rejects a newer format version on load, naming the upgrade direction', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { const m = { version: 99, id: SessionId('v99'), createdAt: 1, cwd: WORK } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) - await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/version/) + const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('rejects an older format version on load without claiming an upgrade path', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const m = { version: -1, id: SessionId('v-older'), createdAt: 1, cwd: WORK } + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toMatch(/older than the supported v0.*no upgrade path/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('rejects an unknown event type on load unless the event is marked ignorable', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const required = meta('unknown-required', WORK) + await ctx.sessionPersistence.create(required) + await ctx.sessionPersistence.append(required.id, [ + ...oneTurnLog(), + { type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 } } as unknown as SessionEvent, + ]) + const failure = await ctx.sessionPersistence.load(required.id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toMatch(/event type "future\/event".*not marked ignorable/) + + const skippable = meta('unknown-ignorable', WORK) + await ctx.sessionPersistence.create(skippable) + await ctx.sessionPersistence.append(skippable.id, [ + ...oneTurnLog(), + { type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 }, ignorable: true } as unknown as SessionEvent, + ]) + const loaded = await ctx.sessionPersistence.load(skippable.id) + expect(loaded.events.some(event => (event.type as string) === 'future/event')).toBe(true) } finally { await fiber.dispose() await fix.cleanup() diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 173d4222cb..e95f78a99a 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -13,6 +13,7 @@ import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/persistence-catalog.md' +const OUT_RUNTIME_TYPES = 'packages/core/session/src/known-event-types.ts' /** The fenced-block info string for generated declaration blocks (skipped by * doc-typecheck, since their imported types are not standalone-compilable). */ @@ -382,31 +383,79 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv return lines.join('\n') } -/** CLI entry: default writes the catalog, `--check` fails if the committed copy +/** + * Render the runtime known-vocabulary module: every event type the packages in + * this repo can write, as a generated `ReadonlySet` the read path checks + * unknown-type refusal against (`SessionEvent.ignorable` contract). + */ +export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string { + const names = [...new Set(events.map(e => e.name))].sort() + return [ + '/**', + ' * GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run', + ' * `pnpm run gen-persistence-catalog` to regenerate (verified fresh by', + ' * `pnpm run verify-persistence-catalog`, part of `doc-sync`).', + ' * @module @deepseek-ai/dsh-session/known-event-types', + ' */', + '', + '/**', + ' * Every `SessionEventMap` member declared in this repository — the event', + ' * vocabulary this build understands. The persistence read path refuses to', + ' * interpret a log containing a type outside this set unless the event', + ' * carries the envelope\'s `ignorable` marker (see `SessionEvent.ignorable`', + ' * in `./types.ts`): such a log was likely written by a newer harness, and', + ' * silently skipping a required event would reconstruct a wrong session.', + ' * Downstream (out-of-repo) plugin events are outside this list by', + ' * construction; a registration surface for them is deferred until such a', + ' * consumer exists.', + ' */', + 'export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([', + ...names.map(name => ` '${name}',`), + '])', + '', + ].join('\n') +} + +/** One generated artifact: repo-relative target and its freshly-rendered content. */ +interface GeneratedArtifact { + readonly out: string + readonly content: string +} + +/** CLI entry: default writes the artifacts, `--check` fails if a committed copy * is stale. Guarded behind an entry-point check so importing this module for - * tests neither regenerates the committed file nor calls process.exit. */ + * tests neither regenerates the committed files nor calls process.exit. */ function main(): void { - const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()), collectEventEnvelopeTypes()) + const events = annotateSurface(collectLogEvents(), collectSurfaceEventTypes()) + const artifacts: GeneratedArtifact[] = [ + { out: OUT, content: render(events, collectEventEnvelopeTypes()) }, + { out: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) }, + ] if (process.argv.includes('--check')) { - let committed: string | null = null - try { - committed = readFileSync(resolve(root, OUT), 'utf8') - } catch { - // Only ENOENT (not yet generated) is expected; a present-but-unreadable - // file is not a state this repo produces. Either way the remedy is the - // same — regenerate — so treat a read failure as "stale". - committed = null - } - if (committed === content) { - console.log(`gen-persistence-catalog: ${OUT} is up to date.`) + const stale = artifacts.filter((artifact) => { + let committed: string | null = null + try { + committed = readFileSync(resolve(root, artifact.out), 'utf8') + } catch { + // Only ENOENT (not yet generated) is expected; a present-but-unreadable + // file is not a state this repo produces. Either way the remedy is the + // same — regenerate — so treat a read failure as "stale". + committed = null + } + return committed !== artifact.content + }) + if (stale.length === 0) { + console.log(`gen-persistence-catalog: ${artifacts.map(a => a.out).join(', ')} are up to date.`) process.exit(0) } - console.error(`gen-persistence-catalog: ${OUT} is stale. Run \`pnpm run gen-persistence-catalog\` and commit ${OUT}.`) + console.error(`gen-persistence-catalog: ${stale.map(a => a.out).join(', ')} stale. Run \`pnpm run gen-persistence-catalog\` and commit the result.`) process.exit(1) } - writeFileSync(resolve(root, OUT), content) - console.log(`gen-persistence-catalog: wrote ${OUT}.`) + for (const artifact of artifacts) { + writeFileSync(resolve(root, artifact.out), artifact.content) + console.log(`gen-persistence-catalog: wrote ${artifact.out}.`) + } } // Run only when invoked as a script, not when imported by a test. From 732bcb7ef1c96d42aaefb55b95770c140b8e9549 Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Mon, 10 Aug 2026 15:44:17 +0800 Subject: [PATCH 225/229] test(acp): re-record cordis-inspect-jsdoc snapshot for the ignorable envelope field --- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 6fa938c18e..8173c4aa07 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present this agent's tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent's model sees.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record<string, never>;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"d422878c-c566-461b-9b6b-a61d241022ea"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present this agent's tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent's model sees.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n ignorable?: true;\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record<string, never>;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise<unknown>;\n finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"eb20999e-deb2-4abe-8517-14de8a6ca238"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} From 0a95a9eed85e2a1b98549a21614810c536eab681 Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Mon, 10 Aug 2026 15:53:16 +0800 Subject: [PATCH 226/229] fix(session): refuse foreign format versions before parsing current structure Review round: the JSONL backend now refuses a foreign header version straight from the raw header line, before validating today's header shape or decoding any event row, so a structurally different future format reports the upgrade direction instead of corruption (shared message builder sessionFormatVersionRefusal). HMR live-prefix adoption runs the unknown-type guard like the other read paths. The appendCore comment now states why the unknown-type guard is read-side only, the loadStoredFrom JSDoc and README pin the seek-vs-sequential refusal-scope divergence, and the generated catalog preamble lists the ignorable envelope field. --- ...10-session-log-version-mechanism.i18n.yaml | 4 +-- ...026-08-10-session-log-version-mechanism.md | 2 +- ...-08-10-session-log-version-mechanism.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +-- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/subsystems/persistence.i18n.yaml | 4 +-- docs/subsystems/persistence.md | 4 +-- docs/subsystems/persistence.zh.md | 4 +-- .../session-persistence-jsonl/src/format.ts | 20 ++++++++++- .../session-persistence-jsonl/src/index.ts | 36 ++++++++++++------- .../tests/jsonl.spec.ts | 21 ++++++++++- .../session-persistence/README.i18n.yaml | 4 +-- .../session/session-persistence/README.md | 2 +- .../session/session-persistence/README.zh.md | 2 +- .../session-persistence/src/coordinator.ts | 36 +++++++++++++++---- .../session/session-persistence/src/index.ts | 1 + scripts/gen-persistence-catalog.ts | 2 +- 18 files changed, 112 insertions(+), 40 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml index a5c4c2044f..ee249b18ea 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md -2026-08-10-session-log-version-mechanism.md: 5358edfe15091379f5b0bbbe8e3e9d0580171c03 -2026-08-10-session-log-version-mechanism.zh.md: b790338c87c78cadda0744dc02d18a5000ffe5ff +2026-08-10-session-log-version-mechanism.md: 25eb1230a254219c827b1d2750dba367b113f9f7 +2026-08-10-session-log-version-mechanism.zh.md: c47670f2de77773c17c9595eff442bf7f1e8ec3e diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md index 5358edfe15..25eb1230a2 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -20,7 +20,7 @@ Session logs must be upgradable after release, and the runtime that ships first ## Consequences -What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. +What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating today's header shape or decoding any event row, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md index b790338c87..c47670f2de 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -20,7 +20,7 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决 ## 影响 -v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 升到 15)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建;写入侧目前不写 `ignorable`(还没有生产者需要它),`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话,预发布立场接受这一点,而且拒绝是显式的而非静默的。 +v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 升到 15)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建;写入侧目前不写 `ignorable`(还没有生产者需要它),`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话,预发布立场接受这一点,而且拒绝是显式的而非静默的。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验当前 header 形状、解码任何事件行之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。 ## 曾考虑的替代方案 diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 12fe94e64c..fb2d7d3d3a 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 2b150ba09eea4365fd0559c68d6f9499ae336933 -persistence-catalog.zh.md: 0ca78a63e85705aaba9c9727c22509891670f42d +persistence-catalog.md: 88d8f833ce3e6c51692db74519279a5354a1759b +persistence-catalog.zh.md: 5ab0fa0c6ccb099ba10b9021625f20486a02d94c diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 2b150ba09e..88d8f833ce 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -7,7 +7,7 @@ Every event type that can appear in a session's durable event log: the complete This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md). -The envelope declarations below compose each event's `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction. +The envelope declarations below compose each event's `type`, monotonic `seq`, epoch-ms `time`, `data`, the optional `ignorable` unknown-type skip marker, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction. ## Event envelope diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 0ca78a63e8..5ab0fa0c6c 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -9,7 +9,7 @@ 英文源文件根据源码生成(`scripts/gen-persistence-catalog.ts`),并由 `pnpm run verify-persistence-catalog`(`doc-sync`(文档同步门禁)的一部分)验证新鲜度;本中文文件作为经评审对侧通过双语配对维护。声明块保留源码声明和嵌套属性的 JSDoc,只移除其所在接口/模块带来的缩进,并使用 `ts persistence-catalog` 围栏(doc-typecheck 会跳过这些围栏,因为声明引用了其所属模块中的类型)。payload 中的类型名称会链接到记录该类型的页面。参见 [persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md)。 -以下信封声明组合了每个事件的 `type`、单调递增的 `seq`、以 epoch 毫秒表示的 `time`、`data`,以及条件字段 `surfaceOp`/`sourceEventSeqs`。**surface** 表示 `SurfaceEventType` 成员:它会生成一条 LLM(大语言模型)消息,并声明该事件如何加入 surface 列表。**log-only** 表示其他所有事件:这类记录可持久化、可回放,但不参与派生历史。每个 payload 均可进行 JSON 序列化(在 `Session.append` 处强制执行),整个格式固定为 `SESSION_FORMAT_VERSION = 0`:这是预发布格式,不暗示任何兼容性(参见[版本立场](subsystems/persistence.md))。范围仅限本仓库中的包;下游插件可以继续合并其他事件类型,而这些类型按设计不属于本目录。 +以下信封声明组合了每个事件的 `type`、单调递增的 `seq`、以 epoch 毫秒表示的 `time`、`data`、可选的未知类型跳过标记 `ignorable`,以及条件字段 `surfaceOp`/`sourceEventSeqs`。**surface** 表示 `SurfaceEventType` 成员:它会生成一条 LLM(大语言模型)消息,并声明该事件如何加入 surface 列表。**log-only** 表示其他所有事件:这类记录可持久化、可回放,但不参与派生历史。每个 payload 均可进行 JSON 序列化(在 `Session.append` 处强制执行),整个格式固定为 `SESSION_FORMAT_VERSION = 0`:这是预发布格式,不暗示任何兼容性(参见[版本立场](subsystems/persistence.md))。范围仅限本仓库中的包;下游插件可以继续合并其他事件类型,而这些类型按设计不属于本目录。 ## 事件信封 diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index b500928227..f81e040bda 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: de7c5c4d445986fe306a782683a8559b25677c94 -persistence.zh.md: a52506aa86418f66e6b1a372020cc316f67cc1c7 +persistence.md: 7deaa9b30b5a6b1e3cbdcc38255b3974b5abf477 +persistence.zh.md: c5afcf67319da408b739d41b2b7ad3eb434ffbad diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index de7c5c4d44..7deaa9b30b 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -89,7 +89,7 @@ interface SessionHeader { ## Format refusal — logs a build cannot faithfully read -A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). +A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating today's header shape or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). ## `CreateSessionOptions` — seeding and metadata @@ -346,5 +346,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot Types: [SessionEvent](session.md) · [SessionId](core.md) -Source: [`packages/session/session-persistence/src/index.ts:73`](../../packages/session/session-persistence/src/index.ts) +Source: [`packages/session/session-persistence/src/index.ts:74`](../../packages/session/session-persistence/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index a52506aa86..c5afcf6731 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -89,7 +89,7 @@ interface SessionHeader { ## 格式拒绝:本构建无法可靠读取的日志 -后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时说明本构建没有升级路径。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。设计理由与推迟建设的升级器链见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)。 +后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时说明本构建没有升级路径。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。JSONL 后端直接从原始 header 行拒绝外来版本,先于当前 header 形状校验和任何事件行解码,因此结构完全不同的未来格式仍会报告升级方向,绝不会报"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。设计理由与推迟建设的升级器链见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)。 ## `CreateSessionOptions`:seed 与元数据 @@ -346,5 +346,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot Types: [SessionEvent](session.md) · [SessionId](core.md) -Source: [`packages/session/session-persistence/src/index.ts:73`](../../packages/session/session-persistence/src/index.ts) +Source: [`packages/session/session-persistence/src/index.ts:74`](../../packages/session/session-persistence/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index 809982f94d..2923b9e09b 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -9,8 +9,9 @@ */ import { join } from 'node:path' -import { decodeStorageRecord, packChunkRuns } from '@deepseek-ai/dsh-session' +import { decodeStorageRecord, packChunkRuns, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session' +import { SessionFormatUnsupportedError, sessionFormatVersionRefusal } from '@deepseek-ai/dsh-session-persistence' /** Physical encoding selected for JSONL session artifacts. */ export type JsonlCompression = 'zstd' | 'none' @@ -229,6 +230,22 @@ interface SessionLogScan { } /** Parse one complete header record supplied independently from event rows. */ +/** + * Refuse a header carrying a format version this build does not read BEFORE + * validating the current header shape or decoding any event row: a future + * format need not satisfy today's structural checks at all, and its user must + * see "upgrade the harness", never "corrupt session log". + * @param parsed - the JSON-parsed first line of a session artifact. + */ +function refuseForeignFormatVersion(parsed: unknown): void { + if (typeof parsed !== 'object' || parsed === null) return + const { version, id } = parsed as { version?: unknown; id?: unknown } + if (typeof version !== 'number' || version === SESSION_FORMAT_VERSION) return + throw new SessionFormatUnsupportedError( + sessionFormatVersionRefusal(typeof id === 'string' ? id : String(id), version), + ) +} + function parseHeaderRecord(record: Buffer): SessionHeader { if (record.length === 0 || record.at(-1) !== 0x0A || record.indexOf(0x0A) !== record.length - 1) { throw new Error('empty or header-less session log') @@ -239,6 +256,7 @@ function parseHeaderRecord(record: Buffer): SessionHeader { } catch { throw new Error('corrupt session log: header line is not valid JSON') } + refuseForeignFormatVersion(parsed) if (!isHeaderLine(parsed)) { throw new Error('corrupt session log: first line is not a session header') } diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index a3a4e04164..6411a077cb 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -16,7 +16,7 @@ import { scheduler } from 'node:timers/promises' import { randomBytes } from 'node:crypto' import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, - SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, + SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, SessionFormatUnsupportedError, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' @@ -256,19 +256,29 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } let prefix: Omit<StoredPrefix<JsonlTornMarker>, 'revision'> - if (this.compression === 'zstd') { - prefix = await this.readZstdPrefix(buffer, signal) - } else { - signal?.throwIfAborted() - const { meta, events, committedBytes } = scanLog(buffer) - signal?.throwIfAborted() - prefix = { - meta, - events, - ...committedBytes < buffer.byteLength - ? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } } - : {}, + try { + if (this.compression === 'zstd') { + prefix = await this.readZstdPrefix(buffer, signal) + } else { + signal?.throwIfAborted() + const { meta, events, committedBytes } = scanLog(buffer) + signal?.throwIfAborted() + prefix = { + meta, + events, + ...committedBytes < buffer.byteLength + ? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } } + : {}, + } } + } catch (error: unknown) { + // A parse-time format refusal predates any SessionHeader, so the + // coordinator's locate-based enrichment cannot run; attach the artifact + // this read actually refused. + if (error instanceof SessionFormatUnsupportedError && error.location === undefined) { + throw new SessionFormatUnsupportedError(`${error.message} (raw log: ${path})`, { kind: 'jsonl', path }) + } + throw error } signal?.throwIfAborted() await this.assertStoredIdentity(path, prefix.meta, expectedId, signal) diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 70781a5d20..733594baf7 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { isAbsolute, join, relative, resolve } from 'node:path' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -187,6 +187,25 @@ describe('SessionPersistenceJsonl: format helpers', () => { await fiber.dispose() }) + it('refuses a structurally foreign future header as unsupported, not corrupt', async () => { + const absoluteRoot = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' }) + // A future format need not satisfy today's header shape at all (no + // createdAt, unknown fields): the version must be refused before shape + // validation, so the user sees the upgrade direction. + const id = SessionId('future-shape') + const path = rawLogPath(resolve(absoluteRoot), '/work', id) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id, futureOnly: true })}\n{"future":"row"}\n`) + const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/) + expect(failure?.message).toContain(`(raw log: ${path})`) + await fiber.dispose() + }) + it('points a format refusal at the raw log path', async () => { const absoluteRoot = await freshRoot() const ctx = new Context() diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index edb755197c..eed71ad212 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md -README.md: 7e62360ccf47151f5c450685bfebe6e89bbf187b -README.zh.md: 3d819ef0ab4f85c83c2e640f627e36341318ac35 +README.md: 324c00b3202bd136566137e1bd398b29d2ea4b82 +README.zh.md: 2ef5e9a90f0323f8edf8fdc4f936c41ca7e08c70 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 7e62360ccf..324c00b320 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -16,7 +16,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `prepare(id, signal?): Promise<SessionPreparation>` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. | | `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and malformed records reject as `SessionPersistenceCorruptionError`, while an unsupported format `version` or an event type unknown to this build (without the envelope's `ignorable` marker) refuses as `SessionFormatUnsupportedError`, naming the refusal direction and the raw log path when the backend keeps one artifact per session. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that apply only events after a stored sequence number. | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Unknown-type refusal follows that access pattern: a seek read checks only the returned suffix, while the sequential fallback also refuses on an unknown required event below the window. Intended for checkpoint consumers that apply only events after a stored sequence number. | | `list(signal?): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 3d819ef0ab..2ef5e9a90f 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -16,7 +16,7 @@ | `prepare(id, signal?): Promise<SessionPreparation>` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose 时将未发布 reservation 释放回有界缓存。 | | `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏和格式错误的记录以 `SessionPersistenceCorruptionError` 拒绝,不支持的格式 `version` 或本构建不认识且信封未带 `ignorable` 标记的事件类型以 `SessionFormatUnsupportedError` 拒绝,消息说明拒绝方向,并在后端为每个会话保留独立文件时给出原始日志路径。 | | `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。供 checkpoint 消费方只应用已存序号之后的事件。 | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。未知类型拒绝遵循同一读取方式:寻址读取只检查返回的后缀,顺序回退路径还会拒绝窗口以下的未知必需事件。供 checkpoint 消费方只应用已存序号之后的事件。 | | `list(signal?): Promise<SessionHeader[]>` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 | diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 6049868f98..eeeb8a5778 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -64,6 +64,22 @@ export class SessionFormatUnsupportedError extends Error { } } +/** + * Direction-aware refusal text for a stored session whose format version this + * build does not read. Shared by the coordinator's load-time check and by + * backends that must refuse BEFORE decoding version-dependent structure (a + * future format may not satisfy today's structural checks at all, and the + * user must see "upgrade the harness", never "corrupt"). + * @param id - the stored session id, for message context. + * @param version - the stored format version. + * @returns the stable refusal text, without a raw-log path suffix. + */ +export function sessionFormatVersionRefusal(id: string, version: number): string { + return version > SESSION_FORMAT_VERSION + ? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it` + : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it` +} + /** Coordinator policy supplied by a concrete persistence backend. */ export interface PersistenceCoordinatorOptions { /** Maximum completed unpublished preparations retained for reuse. */ @@ -147,6 +163,11 @@ export interface PersistenceBackend<TornMarker = unknown> { * contains a supported legacy shape whose normalization needs earlier * message-identity facts, in which case the coordinator falls back * to the complete stored prefix. + * Unknown-type refusal follows the same suffix scope: a seek-capable + * backend's `readFrom` checks only the returned suffix, while the + * sequential fallback parses the whole artifact and refuses on an unknown + * required event anywhere in it — over-refusal on the sequential side is + * accepted rather than widening the seek read. * @param id - persisted session id to resolve. * @param fromSeq - first event seq to include (non-negative safe integer, * validated by the coordinator before this hook runs). @@ -660,9 +681,13 @@ export class PersistenceCoordinator<TornMarker = unknown> { private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise<void> { // Every append route converges here: the public service, live write-behind - // drains, and HMR seed/suffix adoption. Keep vocabulary rejection at that - // shared boundary so a stale JavaScript plugin cannot persist an event that - // this same backend will refuse to load. + // drains, and HMR seed/suffix adoption. Legacy-shape rejection stays at + // this shared boundary so a stale JavaScript plugin cannot persist a + // retired shape this backend refuses to load. The unknown-type guard is + // deliberately read-side only: an append-time refusal would stall a live + // session's durability mid-flight, which costs more than a loud refusal at + // the log's next load (trade-off owned by the session-log-version-mechanism + // Agent Note). assertSupportedEvents(events, id) if (events.length === 0) return this.preparations.assertWritable(id) @@ -1020,9 +1045,7 @@ export class PersistenceCoordinator<TornMarker = unknown> { private assertVersion(meta: SessionHeader): void { if (meta.version === SESSION_FORMAT_VERSION) return - throw this.unsupported(meta, meta.version > SESSION_FORMAT_VERSION - ? `session "${meta.id}" uses log format v${meta.version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it` - : `session "${meta.id}" uses log format v${meta.version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it`) + throw this.unsupported(meta, sessionFormatVersionRefusal(meta.id, meta.version)) } /** @@ -1283,6 +1306,7 @@ export class PersistenceCoordinator<TornMarker = unknown> { } this.assertVersion(meta) const storedEvents = snapshotStoredEvents(events, session.header.id) + this.assertEventsSupported(meta, storedEvents) if (!seedCoversPrefix(seed, storedEvents)) { throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`) } diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 62941477ff..97ae8438f1 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -38,6 +38,7 @@ export { PersistenceCoordinator, SessionFormatUnsupportedError, SessionPersistenceCorruptionError, + sessionFormatVersionRefusal, } from './coordinator.ts' export type { PersistenceBackend, diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index e95f78a99a..debc165eab 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -360,7 +360,7 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv '', 'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md).', '', - 'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.', + 'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, the optional `ignorable` unknown-type skip marker, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.', '', '## Event envelope', '', From 11bfb21e913d341d0dd567f7f343064c76aee969 Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Mon, 10 Aug 2026 17:07:55 +0800 Subject: [PATCH 227/229] test(session-persistence-jsonl): cover the version guard's non-object and non-string-id paths --- .../tests/jsonl.spec.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 733594baf7..35b3829ed3 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -206,6 +206,40 @@ describe('SessionPersistenceJsonl: format helpers', () => { await fiber.dispose() }) + it('keeps a non-object header line a corruption, not a format refusal', async () => { + const absoluteRoot = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' }) + // Valid JSON that is no object carries no version to compare, so the + // version guard must pass it through to the corruption diagnostics. + const id = SessionId('scalar-header') + const path = rawLogPath(resolve(absoluteRoot), '/work', id) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, '42\n') + const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).not.toBe('SessionFormatUnsupportedError') + expect(failure?.message).toContain('first line is not a session header') + await fiber.dispose() + }) + + it('names a foreign-version header by its stringified non-string id', async () => { + const absoluteRoot = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' }) + // A future header's id field is as untrusted as the rest of its shape: + // the refusal must still name the session it read, not crash on the type. + const id = SessionId('numeric-id') + const path = rawLogPath(resolve(absoluteRoot), '/work', id) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id: 123 })}\n`) + const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toContain('session "123" uses log format v42') + await fiber.dispose() + }) + it('points a format refusal at the raw log path', async () => { const absoluteRoot = await freshRoot() const ctx = new Context() From 5265fd084d94d9d12f393e484ced2b2490e1433a Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Mon, 10 Aug 2026 22:28:47 +0800 Subject: [PATCH 228/229] fix(snapshot): use rescoped Cordis package --- examples/headless-agent/tests/session-format-guard.snapshot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/headless-agent/tests/session-format-guard.snapshot.ts b/examples/headless-agent/tests/session-format-guard.snapshot.ts index d7f327b6b6..ac1b5ae43c 100644 --- a/examples/headless-agent/tests/session-format-guard.snapshot.ts +++ b/examples/headless-agent/tests/session-format-guard.snapshot.ts @@ -8,7 +8,7 @@ import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import SessionStore, { SESSION_FORMAT_VERSION, From d5cab00e4e158b0b4d67c39ca5aeef40bd92f5aa Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Tue, 11 Aug 2026 11:26:17 +0800 Subject: [PATCH 229/229] docs: add benchmark SDK entry point --- BENCHMARK.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 BENCHMARK.md diff --git a/BENCHMARK.md b/BENCHMARK.md new file mode 100644 index 0000000000..6e8f466a1f --- /dev/null +++ b/BENCHMARK.md @@ -0,0 +1,3 @@ +# Running benchmarks + +To run benchmark tasks with the minimal agent composition, follow [Get started with the Python SDK](docs/user/guide/python-sdk.md). The guide covers installation, running [`minimal.cordis.yml`](examples/jsonrpc-agent/minimal.cordis.yml), and isolating workspaces and session IDs between tasks.